This commit is contained in:
hailin 2025-05-18 09:25:43 +08:00
parent 1f7d33527a
commit 0adb553873
338 changed files with 50796 additions and 0 deletions

View File

@ -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

25
chatbot-ui/.eslintrc.json Normal file
View File

@ -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"
}
]
}

46
chatbot-ui/.gitignore vendored Normal file
View File

@ -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

View File

@ -0,0 +1,5 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npm run lint:fix && npm run format:write && git add .

1
chatbot-ui/.nvmrc Normal file
View File

@ -0,0 +1 @@
v20.11.0

45
chatbot-ui/Dockerfile Normal file
View File

@ -0,0 +1,45 @@
# ===== 构建阶段 =====
FROM node:18.20.6 AS builder
# 安装指定版本的 npm 和 pm2
RUN npm install -g npm@10.8.2 \
&& npm install -g pm2@5.4.3
WORKDIR /app
# 拷贝依赖文件并安装生产依赖
COPY package.json package-lock.json ./
RUN npm ci
# 拷贝全部源码
COPY . .
# 构建项目
RUN npm run build
# ===== 运行阶段 =====
FROM node:18.20.6 AS runner
# 安装指定版本的 npm 和 pm2
RUN npm install -g npm@10.8.2 \
&& npm install -g pm2@5.4.3
WORKDIR /app
# 拷贝依赖声明并安装仅生产依赖
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# 拷贝构建产物和依赖
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
# 环境变量与端口
ENV NODE_ENV=production
EXPOSE 3000
# 正确使用 pm2-runtime 保持容器挂起
CMD ["pm2-runtime", "start", "npm", "--name", "chatai-ui", "--", "run", "start"]

292
chatbot-ui/README.md Normal file
View File

@ -0,0 +1,292 @@
# Chatbot UI
The open-source AI chat app for everyone.
<img src="./public/readme/screenshot.png" alt="Chatbot UI" width="600">
## Demo
View the latest demo [here](https://x.com/mckaywrigley/status/1738273242283151777?s=20).
## Updates
Hey everyone! I've heard your feedback and am working hard on a big update.
Things like simpler deployment, better backend compatibility, and improved mobile layouts are on their way.
Be back soon.
-- Mckay
## Official Hosted Version
Use Chatbot UI without having to host it yourself!
Find the official hosted version of Chatbot UI [here](https://chatbotui.com).
## Sponsor
If you find Chatbot UI useful, please consider [sponsoring](https://github.com/sponsors/mckaywrigley) me to support my open-source work :)
## Issues
We restrict "Issues" to actual issues related to the codebase.
We're getting excessive amounts of issues that amount to things like feature requests, cloud provider issues, etc.
If you are having issues with things like setup, please refer to the "Help" section in the "Discussions" tab above.
Issues unrelated to the codebase will likely be closed immediately.
## Discussions
We highly encourage you to participate in the "Discussions" tab above!
Discussions are a great place to ask questions, share ideas, and get help.
Odds are if you have a question, someone else has the same question.
## Legacy Code
Chatbot UI was recently updated to its 2.0 version.
The code for 1.0 can be found on the `legacy` branch.
## Updating
In your terminal at the root of your local Chatbot UI repository, run:
```bash
npm run update
```
If you run a hosted instance you'll also need to run:
```bash
npm run db-push
```
to apply the latest migrations to your live database.
## Local Quickstart
Follow these steps to get your own Chatbot UI instance running locally.
You can watch the full video tutorial [here](https://www.youtube.com/watch?v=9Qq3-7-HNgw).
### 1. Clone the Repo
```bash
git clone https://github.com/mckaywrigley/chatbot-ui.git
```
### 2. Install Dependencies
Open a terminal in the root directory of your local Chatbot UI repository and run:
```bash
npm install
```
### 3. Install Supabase & Run Locally
#### Why Supabase?
Previously, we used local browser storage to store data. However, this was not a good solution for a few reasons:
- Security issues
- Limited storage
- Limits multi-modal use cases
We now use Supabase because it's easy to use, it's open-source, it's Postgres, and it has a free tier for hosted instances.
We will support other providers in the future to give you more options.
#### 1. Install Docker
You will need to install Docker to run Supabase locally. You can download it [here](https://docs.docker.com/get-docker) for free.
#### 2. Install Supabase CLI
**MacOS/Linux**
```bash
brew install supabase/tap/supabase
```
**Windows**
```bash
scoop bucket add supabase https://github.com/supabase/scoop-bucket.git
scoop install supabase
```
#### 3. Start Supabase
In your terminal at the root of your local Chatbot UI repository, run:
```bash
supabase start
```
### 4. Fill in Secrets
#### 1. Environment Variables
In your terminal at the root of your local Chatbot UI repository, run:
```bash
cp .env.local.example .env.local
```
Get the required values by running:
```bash
supabase status
```
Note: Use `API URL` from `supabase status` for `NEXT_PUBLIC_SUPABASE_URL`
Now go to your `.env.local` file and fill in the values.
If the environment variable is set, it will disable the input in the user settings.
#### 2. SQL Setup
In the 1st migration file `supabase/migrations/20240108234540_setup.sql` you will need to replace 2 values with the values you got above:
- `project_url` (line 53): `http://supabase_kong_chatbotui:8000` (default) can remain unchanged if you don't change your `project_id` in the `config.toml` file
- `service_role_key` (line 54): You got this value from running `supabase status`
This prevents issues with storage files not being deleted properly.
### 5. Install Ollama (optional for local models)
Follow the instructions [here](https://github.com/jmorganca/ollama#macos).
### 6. Run app locally
In your terminal at the root of your local Chatbot UI repository, run:
```bash
npm run chat
```
Your local instance of Chatbot UI should now be running at [http://localhost:3000](http://localhost:3000). Be sure to use a compatible node version (i.e. v18).
You can view your backend GUI at [http://localhost:54323/project/default/editor](http://localhost:54323/project/default/editor).
## Hosted Quickstart
Follow these steps to get your own Chatbot UI instance running in the cloud.
Video tutorial coming soon.
### 1. Follow Local Quickstart
Repeat steps 1-4 in "Local Quickstart" above.
You will want separate repositories for your local and hosted instances.
Create a new repository for your hosted instance of Chatbot UI on GitHub and push your code to it.
### 2. Setup Backend with Supabase
#### 1. Create a new project
Go to [Supabase](https://supabase.com/) and create a new project.
#### 2. Get Project Values
Once you are in the project dashboard, click on the "Project Settings" icon tab on the far bottom left.
Here you will get the values for the following environment variables:
- `Project Ref`: Found in "General settings" as "Reference ID"
- `Project ID`: Found in the URL of your project dashboard (Ex: https://supabase.com/dashboard/project/<YOUR_PROJECT_ID>/settings/general)
While still in "Settings" click on the "API" text tab on the left.
Here you will get the values for the following environment variables:
- `Project URL`: Found in "API Settings" as "Project URL"
- `Anon key`: Found in "Project API keys" as "anon public"
- `Service role key`: Found in "Project API keys" as "service_role" (Reminder: Treat this like a password!)
#### 3. Configure Auth
Next, click on the "Authentication" icon tab on the far left.
In the text tabs, click on "Providers" and make sure "Email" is enabled.
We recommend turning off "Confirm email" for your own personal instance.
#### 4. Connect to Hosted DB
Open up your repository for your hosted instance of Chatbot UI.
In the 1st migration file `supabase/migrations/20240108234540_setup.sql` you will need to replace 2 values with the values you got above:
- `project_url` (line 53): Use the `Project URL` value from above
- `service_role_key` (line 54): Use the `Service role key` value from above
Now, open a terminal in the root directory of your local Chatbot UI repository. We will execute a few commands here.
Login to Supabase by running:
```bash
supabase login
```
Next, link your project by running the following command with the "Project ID" you got above:
```bash
supabase link --project-ref <project-id>
```
Your project should now be linked.
Finally, push your database to Supabase by running:
```bash
supabase db push
```
Your hosted database should now be set up!
### 3. Setup Frontend with Vercel
Go to [Vercel](https://vercel.com/) and create a new project.
In the setup page, import your GitHub repository for your hosted instance of Chatbot UI. Within the project Settings, in the "Build & Development Settings" section, switch Framework Preset to "Next.js".
In environment variables, add the following from the values you got above:
- `NEXT_PUBLIC_SUPABASE_URL`
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
- `SUPABASE_SERVICE_ROLE_KEY`
- `NEXT_PUBLIC_OLLAMA_URL` (only needed when using local Ollama models; default: `http://localhost:11434`)
You can also add API keys as environment variables.
- `OPENAI_API_KEY`
- `AZURE_OPENAI_API_KEY`
- `AZURE_OPENAI_ENDPOINT`
- `AZURE_GPT_45_VISION_NAME`
For the full list of environment variables, refer to the '.env.local.example' file. If the environment variables are set for API keys, it will disable the input in the user settings.
Click "Deploy" and wait for your frontend to deploy.
Once deployed, you should be able to use your hosted instance of Chatbot UI via the URL Vercel gives you.
## Contributing
We are working on a guide for contributing.
## Contact
Message Mckay on [Twitter/X](https://twitter.com/mckaywrigley)

View File

@ -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)
})
})

View File

@ -0,0 +1,5 @@
node_modules/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

View File

@ -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
}
}
}

View File

@ -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"
}
}

View File

@ -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,
// },
});

View File

@ -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

View File

@ -0,0 +1,7 @@
"use client"
import { ChatUI } from "@/components/chat/chat-ui"
export default function ChatIDPage() {
return <ChatUI />
}

View File

@ -0,0 +1,62 @@
"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"
import { useTranslation } from 'react-i18next'
export default function ChatPage() {
useHotkey("o", () => handleNewChat())
useHotkey("l", () => {
handleFocusChatInput()
})
const { chatMessages } = useContext(ChatbotUIContext)
const { handleNewChat, handleFocusChatInput } = useChatHandler()
const { theme } = useTheme()
const { t } = useTranslation()
return (
<>
{chatMessages.length === 0 ? (
<div className="relative flex h-full flex-col items-center justify-center">
<div className="top-50% left-50% -translate-x-50% -translate-y-50% absolute mb-20">
<Brand theme={theme === "dark" ? "dark" : "light"} />
</div>
<div className="absolute left-2 top-2">
<QuickSettings />
</div>
<div className="absolute right-2 top-2">
<ChatSettings />
</div>
<div className="flex grow flex-col items-center justify-center" />
<div className="w-full min-w-[300px] items-end px-2 pb-3 pt-0 sm:w-[600px] sm:pb-8 sm:pt-5 md:w-[700px] lg:w-[700px] xl:w-[800px]">
<ChatInput />
</div>
<div className="absolute bottom-2 right-2 hidden md:block lg:bottom-4 lg:right-4">
<ChatHelp />
</div>
</div>
) : (
<ChatUI />
)}
</>
)
}

View File

@ -0,0 +1,193 @@
"use client"
import { usePathname } from "next/navigation"
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"
import { useTranslation } from 'react-i18next'
interface WorkspaceLayoutProps {
children: ReactNode
}
export default function WorkspaceLayout({ children }: WorkspaceLayoutProps) {
const { t } = useTranslation()
const router = useRouter()
const pathname = usePathname() // 获取当前路径
// 提取当前路径中的 locale 部分
const locale = pathname.split("/")[1] || "en"
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) {
// 跳转到带有 locale 的登录页面
return router.push(`/${locale}/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 ||
t("chat.promptPlaceholder"),
temperature: workspace?.default_temperature || 0.5,
contextLength: workspace?.default_context_length || 4096,
includeProfileContext: workspace?.include_profile_context || true,
includeWorkspaceInstructions:
workspace?.include_workspace_instructions || true,
embeddingsProvider:
(workspace?.embeddings_provider as "openai" | "local") || "openai"
})
setLoading(false)
}
if (loading) {
return <Loading />
}
return <Dashboard>{children}</Dashboard>
}

View File

@ -0,0 +1,14 @@
"use client"
import { ChatbotUIContext } from "@/context/context"
import { useContext } from "react"
export default function WorkspacePage() {
const { selectedWorkspace } = useContext(ChatbotUIContext)
return (
<div className="flex h-screen w-full flex-col items-center justify-center">
<div className="text-4xl">{selectedWorkspace?.name}</div>
</div>
)
}

View File

@ -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;
}
}

View File

@ -0,0 +1,7 @@
export default function HelpPage() {
return (
<div className="size-screen flex flex-col items-center justify-center">
<div className="text-4xl">Help under construction.</div>
</div>
)
}

View File

@ -0,0 +1,170 @@
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 = "ChatAI UI"
const APP_DEFAULT_TITLE = "ChatAI UI"
const APP_TITLE_TEMPLATE = "%s - ChatAI UI"
const APP_DESCRIPTION = "ChaAI 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 async function generateMetadata({
params: { locale }
}: {
params: { locale: string }
}): Promise<Metadata> {
const { t } = await initTranslations(locale, ["translation"])
const appName = t("meta.appName")
const defaultTitle = t("meta.defaultTitle")
const description = t("meta.description")
const titleTemplate = `%s - ${defaultTitle}`
return {
applicationName: appName,
title: {
default: defaultTitle,
template: titleTemplate
},
description,
manifest: "/manifest.json",
appleWebApp: {
capable: true,
statusBarStyle: "black",
title: defaultTitle
},
formatDetection: {
telephone: false
},
openGraph: {
type: "website",
siteName: appName,
title: {
default: defaultTitle,
template: titleTemplate
},
description
},
twitter: {
card: "summary",
title: {
default: defaultTitle,
template: titleTemplate
},
description
}
}
}
export const viewport: Viewport = {
themeColor: "#000000"
}
const i18nNamespaces = ["translation"]
export default async function RootLayout({
children,
params: { locale }
}: RootLayoutProps) {
const cookieStore = cookies()
// 遍历所有 cookies
for (const cookie of cookieStore.getAll()) {
console.log(`🍪 Cookie: ${cookie.name} = ${cookie.value}`);
}
const supabase = createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value
}
}
}
)
// const session = (await supabase.auth.getSession()).data.session
const { data, error } = await supabase.auth.getSession();
if (error) {
console.log("[layout.tsx]............Session Error: ", error);
} else {
console.log("[layout.tsx]............Session Data: ", data.session);
}
const { t, resources } = await initTranslations(locale, i18nNamespaces)
console.log("[layout.tsx]..............current locale: ", {locale});
return (
<html lang="en" suppressHydrationWarning>
<body className={inter.className}>
<Providers attribute="class" defaultTheme="dark">
<TranslationsProvider
namespaces={i18nNamespaces}
locale={locale}
resources={resources}
>
<Toaster richColors position="top-center" duration={3000} />
<div className="bg-background text-foreground flex h-dvh flex-col items-center overflow-x-auto">
{data.session ? <GlobalState>{children}</GlobalState> : children}
</div>
</TranslationsProvider>
</Providers>
</body>
</html>
)
}

View File

@ -0,0 +1,9 @@
import { IconLoader2 } from "@tabler/icons-react"
export default function Loading() {
return (
<div className="flex size-full flex-col items-center justify-center">
<IconLoader2 className="mt-4 size-12 animate-spin" />
</div>
)
}

View File

@ -0,0 +1,260 @@
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"
import initTranslations from "@/lib/i18n";
export const metadata: Metadata = {
title: "Login"
}
export default async function Login({
searchParams,
params: { locale },
}: {
searchParams: { message: string; email?: string };
params: { locale: string };
}) {
const cookieStore = cookies()
const localeString = locale;
const { t, resources } = await initTranslations(localeString, ['translation']);
const supabase = createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value
}
}
}
)
const session = (await supabase.auth.getSession()).data.session
console.log("[login page]Login session:", 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)
}
// console.log("[login page]======>Redirecting to workspace:", homeWorkspace.id)
return redirect(`/${localeString}/${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) {
// console.log(`[login page]==================> ${localeString}/login?message=${error.message}`);
// return redirect(`/${localeString}/login?message=${error.message}`)
return redirect(`/${localeString}/login?message=invalidCredentials`)
}
const { data: homeWorkspace, error: homeWorkspaceError } = await supabase
.from("workspaces")
.select("*")
.eq("user_id", data.user.id)
.eq("is_home", true)
.single()
if (!homeWorkspace) {
//const fallbackMessage = String(t("login.unexpectedError"))
throw new Error(
homeWorkspaceError?.message || "An unexpected error occurred"
// homeWorkspaceError?.message || t("login.unexpectedError")
)
}
return redirect(`/${localeString}/${homeWorkspace.id}/chat`)
}
const getEnvVarOrEdgeConfigValue = async (name: string) => {
"use server"
if (process.env.EDGE_CONFIG) {
return await get<string>(name)
}
return process.env[name]
}
const signUp = async (formData: FormData) => {
"use server"
const email = formData.get("email") as string
const password = formData.get("password") as string
const emailDomainWhitelistPatternsString = await getEnvVarOrEdgeConfigValue(
"EMAIL_DOMAIN_WHITELIST"
)
const emailDomainWhitelist = emailDomainWhitelistPatternsString?.trim()
? emailDomainWhitelistPatternsString?.split(",")
: []
const emailWhitelistPatternsString =
await getEnvVarOrEdgeConfigValue("EMAIL_WHITELIST")
const emailWhitelist = emailWhitelistPatternsString?.trim()
? emailWhitelistPatternsString?.split(",")
: []
// If there are whitelist patterns, check if the email is allowed to sign up
if (emailDomainWhitelist.length > 0 || emailWhitelist.length > 0) {
const domainMatch = emailDomainWhitelist?.includes(email.split("@")[1])
const emailMatch = emailWhitelist?.includes(email)
if (!domainMatch && !emailMatch) {
return redirect(
// `/${localeString}/login?message=Email ${email} is not allowed to sign up.`
`/${localeString}/login?message=signupNotAllowed&email=${encodeURIComponent(email)}`
)
}
}
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(`/${localeString}/login?message=${error.message}`)
}
return redirect(`/${localeString}/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=${localeString}/login/password`
})
if (error) {
return redirect(`/${localeString}/login?message=${error.message}`)
}
// const emailtoResetMessage = String(t("login.checkEmailToReset")) // ← 这是字符串
// return redirect(`/${localeString}/login?message=${emailtoResetMessage}`)
return redirect(`/${localeString}/login?message=Check email to reset password`)
}
let translatedMessage: string | null = null;
if (searchParams.message === "signupNotAllowed") {
translatedMessage = t("login.signupNotAllowed", { email: searchParams.email });
} else if (searchParams.message === "signupNotAllowed") {
} else if (searchParams.message) {
translatedMessage = t(`login.${searchParams.message}`);
}
return (
<div className="flex w-full flex-1 flex-col justify-center gap-2 px-8 sm:max-w-md">
<form
className="animate-in text-foreground flex w-full flex-1 flex-col justify-center gap-2"
action={signIn}
>
<Brand />
<Label className="text-md mt-4" htmlFor="email">
{t("login.email")}
</Label>
<Input
className="mb-3 rounded-md border bg-inherit px-4 py-2"
name="email"
placeholder={t("login.emailPlaceholder")}
required
/>
<Label className="text-md" htmlFor="password">
{t("login.password")}
</Label>
<Input
className="mb-6 rounded-md border bg-inherit px-4 py-2"
type="password"
name="password"
placeholder={t("login.passwordPlaceholder")}
/>
<SubmitButton className="mb-2 rounded-md bg-blue-700 px-4 py-2 text-white">
{t("login.loginButton")}
</SubmitButton>
<SubmitButton
formAction={signUp}
className="border-foreground/20 mb-2 rounded-md border px-4 py-2"
>
{t("login.signUpButton")}
</SubmitButton>
<div className="text-muted-foreground mt-1 flex justify-center text-sm">
<span className="mr-1">{t("login.forgotPassword")}</span>
<button
formAction={handleResetPassword}
className="text-primary ml-1 underline hover:opacity-80"
>
{t("login.reset")}
</button>
</div>
{/* {searchParams?.message && (
<p className="bg-foreground/10 text-foreground mt-4 p-4 text-center">
{searchParams.message}
</p>
)} */}
{translatedMessage && (
<p className="bg-foreground/10 text-foreground mt-4 p-4 text-center">
{translatedMessage}
</p>
)}
</form>
</div>
)
}

View File

@ -0,0 +1,55 @@
"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"
import { usePathname } from "next/navigation" // 导入 usePathname
import i18nConfig from "@/i18nConfig"
export default function ChangePasswordPage() {
const [loading, setLoading] = useState(true)
const router = useRouter()
const pathname = usePathname() // 获取当前路径
useEffect(() => {
;(async () => {
const session = (await supabase.auth.getSession()).data.session
if (!session) {
// // 提取当前路径中的 locale 部分
// const locale = pathname.split("/")[1] || "en" // 获取路径中的 locale 部分,如果没有则默认为 "en"
const pathSegments = pathname.split("/").filter(Boolean)
const locales = i18nConfig.locales
const defaultLocale = i18nConfig.defaultLocale
let locale: (typeof locales)[number] = defaultLocale
const segment = pathSegments[0] as (typeof locales)[number]
if (locales.includes(segment)) {
locale = segment
}
const homePath = locale === defaultLocale ? "/" : `/${locale}`
console.log("...........[login page.tsx]")
router.push(`${homePath}/login`)
// router.push(`${locale}/login`)
} else {
setLoading(false)
}
})()
}, [])
if (loading) {
return null
}
return <ChangePassword />
}

View File

@ -0,0 +1,51 @@
"use client"
import { useEffect, useState } from "react"
import { ChatbotUISVG } from "@/components/icons/chatbotui-svg"
import { IconArrowRight } from "@tabler/icons-react"
import { useTheme } from "next-themes"
import Link from "next/link"
import HomeRedirector from "@/components/utility/home-redirector"
import { LanguageSwitcher } from '@/components/ui/language-switcher'
import { useTranslation } from "react-i18next"
export default function HomePage() {
const { theme } = useTheme()
const { t, i18n } = useTranslation()
const [preferredLanguage, setPreferredLanguage] = useState<string>('en') // 默认语言为 'en'
// 根据 localStorage 或 cookie 设置 preferredLanguage
useEffect(() => {
const languageFromStorage = localStorage.getItem('preferred-language') || document.cookie.split('; ').find(row => row.startsWith('preferred-language='))?.split('=')[1];
if (languageFromStorage) {
setPreferredLanguage(languageFromStorage);
// 更新 i18n 的语言设置
i18n.changeLanguage(languageFromStorage); // 通过 i18n 更新默认语言
}
}, []);
return (
<div className="flex size-full flex-col items-center justify-center relative">
<HomeRedirector />
<LanguageSwitcher />
<div>
<ChatbotUISVG theme={theme === "dark" ? "dark" : "light"} scale={0.3} />
</div>
<div className="mt-2 text-4xl font-bold">{t("Company Name")}</div>
<Link
className="mt-4 flex w-[200px] items-center justify-center rounded-md bg-blue-500 p-2 font-semibold"
href={`/${preferredLanguage}/login`}
>
{t("Clock In")}
<IconArrowRight className="ml-1" size={20} />
</Link>
</div>
)
}

View File

@ -0,0 +1,291 @@
'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"
import { useTranslation } from 'react-i18next'
import { usePathname } from "next/navigation"
import i18nConfig from "@/i18nConfig"
export default function SetupPage() {
const {
profile,
setProfile,
setWorkspaces,
setSelectedWorkspace,
setEnvKeyMap,
setAvailableHostedModels,
setAvailableOpenRouterModels
} = useContext(ChatbotUIContext)
const router = useRouter()
const pathname = usePathname() // 获取当前路径
const pathSegments = pathname.split("/").filter(Boolean)
const locales = i18nConfig.locales
const defaultLocale = i18nConfig.defaultLocale
let locale: (typeof locales)[number] = defaultLocale
const segment = pathSegments[0] as (typeof locales)[number]
if (locales.includes(segment)) {
locale = segment
}
const homePath = locale === defaultLocale ? "/" : `/${locale}`
// // 提取当前路径中的 locale 部分
// const locale = pathname.split("/")[1] || "en" // 获取路径中的 locale 部分,如果没有则默认为 "en"
const { t } = useTranslation()
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) {
// 强制跳转到带有 locale 的 login 页面
console.log("...........[setup/page.tsx]")
return router.push(`${homePath}/login`)
// return router.push(`/${locale}/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(`${homePath}/${homeWorkspaceId}/chat`)
// return router.push(`/${locale}/${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(`/${locale}/login`)
return (`${homePath}/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(`${homePath}/${homeWorkspace?.id}/chat`)
// return router.push(`/${locale}/${homeWorkspace?.id}/chat`)
}
const renderStep = (stepNum: number) => {
switch (stepNum) {
// Profile Step
case 1:
return (
<StepContainer
stepDescription={t("setup.LetsCreateYourProfile")}
stepNum={currentStep}
stepTitle={t("setup.WelcomeToChatbotUI")}
onShouldProceed={handleShouldProceed}
showNextButton={!!(username && usernameAvailable)}
showBackButton={false}
>
<ProfileStep
username={username}
usernameAvailable={usernameAvailable}
displayName={displayName}
onUsernameAvailableChange={setUsernameAvailable}
onUsernameChange={setUsername}
onDisplayNameChange={setDisplayName}
/>
</StepContainer>
)
// API Step
case 2:
return (
<StepContainer
stepDescription={t("setup.EnterAPIKeysForEachServiceYoudLikeToUse")}
stepNum={currentStep}
stepTitle={t("setup.SetAPIKeysOptional")}
onShouldProceed={handleShouldProceed}
showNextButton={true}
showBackButton={true}
>
<APIStep
openaiAPIKey={openaiAPIKey}
openaiOrgID={openaiOrgID}
azureOpenaiAPIKey={azureOpenaiAPIKey}
azureOpenaiEndpoint={azureOpenaiEndpoint}
azureOpenai35TurboID={azureOpenai35TurboID}
azureOpenai45TurboID={azureOpenai45TurboID}
azureOpenai45VisionID={azureOpenai45VisionID}
azureOpenaiEmbeddingsID={azureOpenaiEmbeddingsID}
anthropicAPIKey={anthropicAPIKey}
googleGeminiAPIKey={googleGeminiAPIKey}
mistralAPIKey={mistralAPIKey}
groqAPIKey={groqAPIKey}
perplexityAPIKey={perplexityAPIKey}
useAzureOpenai={useAzureOpenai}
onOpenaiAPIKeyChange={setOpenaiAPIKey}
onOpenaiOrgIDChange={setOpenaiOrgID}
onAzureOpenaiAPIKeyChange={setAzureOpenaiAPIKey}
onAzureOpenaiEndpointChange={setAzureOpenaiEndpoint}
onAzureOpenai35TurboIDChange={setAzureOpenai35TurboID}
onAzureOpenai45TurboIDChange={setAzureOpenai45TurboID}
onAzureOpenai45VisionIDChange={setAzureOpenai45VisionID}
onAzureOpenaiEmbeddingsIDChange={setAzureOpenaiEmbeddingsID}
onAnthropicAPIKeyChange={setAnthropicAPIKey}
onGoogleGeminiAPIKeyChange={setGoogleGeminiAPIKey}
onMistralAPIKeyChange={setMistralAPIKey}
onGroqAPIKeyChange={setGroqAPIKey}
onPerplexityAPIKeyChange={setPerplexityAPIKey}
onUseAzureOpenaiChange={setUseAzureOpenai}
openrouterAPIKey={openrouterAPIKey}
onOpenrouterAPIKeyChange={setOpenrouterAPIKey}
/>
</StepContainer>
)
// Finish Step
case 3:
return (
<StepContainer
stepDescription={t("setup.YouAreAllSetUp")}
stepNum={currentStep}
stepTitle={t("setup.SetupComplete")}
onShouldProceed={handleShouldProceed}
showNextButton={true}
showBackButton={true}
>
<FinishStep displayName={displayName} />
</StepContainer>
)
default:
return null
}
}
if (loading) {
return null
}
return (
<div className="flex h-full items-center justify-center">
{renderStep(currentStep)}
</div>
)
}

View File

@ -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
})
}
}

View File

@ -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
})
}
}

View File

@ -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
})
}
}

View File

@ -0,0 +1,66 @@
import { Database } from "@/supabase/types"
import { ChatSettings } from "@/types"
import { createClient } from "@supabase/supabase-js"
import { OpenAIStream, StreamingTextResponse } from "ai"
import { ServerRuntime } from "next"
import OpenAI from "openai"
import { ChatCompletionCreateParamsBase } from "openai/resources/chat/completions.mjs"
export const runtime: ServerRuntime = "edge"
export async function POST(request: Request) {
const json = await request.json()
const { chatSettings, messages, customModelId } = json as {
chatSettings: ChatSettings
messages: any[]
customModelId: string
}
try {
const supabaseAdmin = createClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
const { data: customModel, error } = await supabaseAdmin
.from("models")
.select("*")
.eq("id", customModelId)
.single()
if (!customModel) {
throw new Error(error.message)
}
const custom = new OpenAI({
apiKey: customModel.api_key || "",
baseURL: customModel.base_url
})
const response = await custom.chat.completions.create({
model: chatSettings.model as ChatCompletionCreateParamsBase["model"],
messages: messages as ChatCompletionCreateParamsBase["messages"],
temperature: chatSettings.temperature,
stream: true
})
const stream = OpenAIStream(response)
return new StreamingTextResponse(stream)
} catch (error: any) {
let errorMessage = error.message || "An unexpected error occurred"
const errorCode = error.status || 500
if (errorMessage.toLowerCase().includes("api key not found")) {
errorMessage =
"Custom API Key not found. Please set it in your profile settings."
} else if (errorMessage.toLowerCase().includes("incorrect api key")) {
errorMessage =
"Custom API Key is incorrect. Please fix it in your profile settings."
}
return new Response(JSON.stringify({ message: errorMessage }), {
status: errorCode
})
}
}

View File

@ -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
})
}
}

View File

@ -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
})
}
}

View File

@ -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
})
}
}

View File

@ -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
})
}
}

View File

@ -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
})
}
}

View File

@ -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
})
}
}

View File

@ -0,0 +1,218 @@
import { openapiToFunctions } from "@/lib/openapi-conversion"
import { checkApiKey, getServerProfile } from "@/lib/server/server-chat-helpers"
import { Tables } from "@/supabase/types"
import { ChatSettings } from "@/types"
import { OpenAIStream, StreamingTextResponse } from "ai"
import OpenAI from "openai"
import { ChatCompletionCreateParamsBase } from "openai/resources/chat/completions.mjs"
export async function POST(request: Request) {
const json = await request.json()
const { chatSettings, messages, selectedTools } = json as {
chatSettings: ChatSettings
messages: any[]
selectedTools: Tables<"tools">[]
}
try {
const profile = await getServerProfile()
checkApiKey(profile.openai_api_key, "OpenAI")
const openai = new OpenAI({
apiKey: profile.openai_api_key || "",
organization: profile.openai_organization_id
})
let allTools: OpenAI.Chat.Completions.ChatCompletionTool[] = []
let allRouteMaps = {}
let schemaDetails = []
for (const selectedTool of selectedTools) {
try {
const convertedSchema = await openapiToFunctions(
JSON.parse(selectedTool.schema as string)
)
const tools = convertedSchema.functions || []
allTools = allTools.concat(tools)
const routeMap = convertedSchema.routes.reduce(
(map: Record<string, string>, route) => {
map[route.path.replace(/{(\w+)}/g, ":$1")] = route.operationId
return map
},
{}
)
allRouteMaps = { ...allRouteMaps, ...routeMap }
schemaDetails.push({
title: convertedSchema.info.title,
description: convertedSchema.info.description,
url: convertedSchema.info.server,
headers: selectedTool.custom_headers,
routeMap,
requestInBody: convertedSchema.routes[0].requestInBody
})
} catch (error: any) {
console.error("Error converting schema", error)
}
}
const firstResponse = await openai.chat.completions.create({
model: chatSettings.model as ChatCompletionCreateParamsBase["model"],
messages,
tools: allTools.length > 0 ? allTools : undefined
})
const message = firstResponse.choices[0].message
messages.push(message)
const toolCalls = message.tool_calls || []
if (toolCalls.length === 0) {
return new Response(message.content, {
headers: {
"Content-Type": "application/json"
}
})
}
if (toolCalls.length > 0) {
for (const toolCall of toolCalls) {
const functionCall = toolCall.function
const functionName = functionCall.name
const argumentsString = toolCall.function.arguments.trim()
const parsedArgs = JSON.parse(argumentsString)
// Find the schema detail that contains the function name
const schemaDetail = schemaDetails.find(detail =>
Object.values(detail.routeMap).includes(functionName)
)
if (!schemaDetail) {
throw new Error(`Function ${functionName} not found in any schema`)
}
const pathTemplate = Object.keys(schemaDetail.routeMap).find(
key => schemaDetail.routeMap[key] === functionName
)
if (!pathTemplate) {
throw new Error(`Path for function ${functionName} not found`)
}
const path = pathTemplate.replace(/:(\w+)/g, (_, paramName) => {
const value = parsedArgs.parameters[paramName]
if (!value) {
throw new Error(
`Parameter ${paramName} not found for function ${functionName}`
)
}
return encodeURIComponent(value)
})
if (!path) {
throw new Error(`Path for function ${functionName} not found`)
}
// Determine if the request should be in the body or as a query
const isRequestInBody = schemaDetail.requestInBody
let data = {}
if (isRequestInBody) {
// If the type is set to body
let headers = {
"Content-Type": "application/json"
}
// Check if custom headers are set
const customHeaders = schemaDetail.headers // Moved this line up to the loop
// Check if custom headers are set and are of type string
if (customHeaders && typeof customHeaders === "string") {
let parsedCustomHeaders = JSON.parse(customHeaders) as Record<
string,
string
>
headers = {
...headers,
...parsedCustomHeaders
}
}
const fullUrl = schemaDetail.url + path
const bodyContent = parsedArgs.requestBody || parsedArgs
const requestInit = {
method: "POST",
headers,
body: JSON.stringify(bodyContent) // Use the extracted requestBody or the entire parsedArgs
}
const response = await fetch(fullUrl, requestInit)
if (!response.ok) {
data = {
error: response.statusText
}
} else {
data = await response.json()
}
} else {
// If the type is set to query
const queryParams = new URLSearchParams(
parsedArgs.parameters
).toString()
const fullUrl =
schemaDetail.url + path + (queryParams ? "?" + queryParams : "")
let headers = {}
// Check if custom headers are set
const customHeaders = schemaDetail.headers
if (customHeaders && typeof customHeaders === "string") {
headers = JSON.parse(customHeaders)
}
const response = await fetch(fullUrl, {
method: "GET",
headers: headers
})
if (!response.ok) {
data = {
error: response.statusText
}
} else {
data = await response.json()
}
}
messages.push({
tool_call_id: toolCall.id,
role: "tool",
name: functionName,
content: JSON.stringify(data)
})
}
}
const secondResponse = await openai.chat.completions.create({
model: chatSettings.model as ChatCompletionCreateParamsBase["model"],
messages,
stream: true
})
const stream = OpenAIStream(secondResponse)
return new StreamingTextResponse(stream)
} catch (error: any) {
console.error(error)
const errorMessage = error.error?.message || "An unexpected error occurred"
const errorCode = error.status || 500
return new Response(JSON.stringify({ message: errorMessage }), {
status: errorCode
})
}
}

View File

@ -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
})
}
}

View File

@ -0,0 +1,38 @@
import { isUsingEnvironmentKey } from "@/lib/envs"
import { createResponse } from "@/lib/server/server-utils"
import { EnvKey } from "@/types/key-type"
import { VALID_ENV_KEYS } from "@/types/valid-keys"
export async function GET() {
const envKeyMap: Record<string, VALID_ENV_KEYS> = {
azure: VALID_ENV_KEYS.AZURE_OPENAI_API_KEY,
openai: VALID_ENV_KEYS.OPENAI_API_KEY,
google: VALID_ENV_KEYS.GOOGLE_GEMINI_API_KEY,
anthropic: VALID_ENV_KEYS.ANTHROPIC_API_KEY,
mistral: VALID_ENV_KEYS.MISTRAL_API_KEY,
groq: VALID_ENV_KEYS.GROQ_API_KEY,
perplexity: VALID_ENV_KEYS.PERPLEXITY_API_KEY,
openrouter: VALID_ENV_KEYS.OPENROUTER_API_KEY,
openai_organization_id: VALID_ENV_KEYS.OPENAI_ORGANIZATION_ID,
azure_openai_endpoint: VALID_ENV_KEYS.AZURE_OPENAI_ENDPOINT,
azure_gpt_35_turbo_name: VALID_ENV_KEYS.AZURE_GPT_35_TURBO_NAME,
azure_gpt_45_vision_name: VALID_ENV_KEYS.AZURE_GPT_45_VISION_NAME,
azure_gpt_45_turbo_name: VALID_ENV_KEYS.AZURE_GPT_45_TURBO_NAME,
azure_embeddings_name: VALID_ENV_KEYS.AZURE_EMBEDDINGS_NAME
}
const isUsingEnvKeyMap = Object.keys(envKeyMap).reduce<
Record<string, boolean>
>((acc, provider) => {
const key = envKeyMap[provider]
if (key) {
acc[provider] = isUsingEnvironmentKey(key as EnvKey)
}
return acc
}, {})
return createResponse({ isUsingEnvKeyMap }, 200)
}

View File

@ -0,0 +1,121 @@
import { generateLocalEmbedding } from "@/lib/generate-local-embedding"
import { processDocX } from "@/lib/retrieval/processing"
import { checkApiKey, getServerProfile } from "@/lib/server/server-chat-helpers"
import { Database } from "@/supabase/types"
import { FileItemChunk } from "@/types"
import { createClient } from "@supabase/supabase-js"
import { NextResponse } from "next/server"
import OpenAI from "openai"
export async function POST(req: Request) {
const json = await req.json()
const { text, fileId, embeddingsProvider, fileExtension } = json as {
text: string
fileId: string
embeddingsProvider: "openai" | "local"
fileExtension: string
}
try {
const supabaseAdmin = createClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
const profile = await getServerProfile()
if (embeddingsProvider === "openai") {
if (profile.use_azure_openai) {
checkApiKey(profile.azure_openai_api_key, "Azure OpenAI")
} else {
checkApiKey(profile.openai_api_key, "OpenAI")
}
}
let chunks: FileItemChunk[] = []
switch (fileExtension) {
case "docx":
chunks = await processDocX(text)
break
default:
return new NextResponse("Unsupported file type", {
status: 400
})
}
let embeddings: any = []
let openai
if (profile.use_azure_openai) {
openai = new OpenAI({
apiKey: profile.azure_openai_api_key || "",
baseURL: `${profile.azure_openai_endpoint}/openai/deployments/${profile.azure_openai_embeddings_id}`,
defaultQuery: { "api-version": "2023-12-01-preview" },
defaultHeaders: { "api-key": profile.azure_openai_api_key }
})
} else {
openai = new OpenAI({
apiKey: profile.openai_api_key || "",
organization: profile.openai_organization_id
})
}
if (embeddingsProvider === "openai") {
const response = await openai.embeddings.create({
model: "text-embedding-3-small",
input: chunks.map(chunk => chunk.content)
})
embeddings = response.data.map((item: any) => {
return item.embedding
})
} else if (embeddingsProvider === "local") {
const embeddingPromises = chunks.map(async chunk => {
try {
return await generateLocalEmbedding(chunk.content)
} catch (error) {
console.error(`Error generating embedding for chunk: ${chunk}`, error)
return null
}
})
embeddings = await Promise.all(embeddingPromises)
}
const file_items = chunks.map((chunk, index) => ({
file_id: fileId,
user_id: profile.user_id,
content: chunk.content,
tokens: chunk.tokens,
openai_embedding:
embeddingsProvider === "openai"
? ((embeddings[index] || null) as any)
: null,
local_embedding:
embeddingsProvider === "local"
? ((embeddings[index] || null) as any)
: null
}))
await supabaseAdmin.from("file_items").upsert(file_items)
const totalTokens = file_items.reduce((acc, item) => acc + item.tokens, 0)
await supabaseAdmin
.from("files")
.update({ tokens: totalTokens })
.eq("id", fileId)
return new NextResponse("Embed Successful", {
status: 200
})
} catch (error: any) {
console.error(error)
const errorMessage = error.error?.message || "An unexpected error occurred"
const errorCode = error.status || 500
return new Response(JSON.stringify({ message: errorMessage }), {
status: errorCode
})
}
}

View File

@ -0,0 +1,175 @@
import { generateLocalEmbedding } from "@/lib/generate-local-embedding"
import {
processCSV,
processJSON,
processMarkdown,
processPdf,
processTxt
} from "@/lib/retrieval/processing"
import { checkApiKey, getServerProfile } from "@/lib/server/server-chat-helpers"
import { Database } from "@/supabase/types"
import { FileItemChunk } from "@/types"
import { createClient } from "@supabase/supabase-js"
import { NextResponse } from "next/server"
import OpenAI from "openai"
export async function POST(req: Request) {
try {
const supabaseAdmin = createClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
const profile = await getServerProfile()
const formData = await req.formData()
const file_id = formData.get("file_id") as string
const embeddingsProvider = formData.get("embeddingsProvider") as string
const { data: fileMetadata, error: metadataError } = await supabaseAdmin
.from("files")
.select("*")
.eq("id", file_id)
.single()
if (metadataError) {
throw new Error(
`Failed to retrieve file metadata: ${metadataError.message}`
)
}
if (!fileMetadata) {
throw new Error("File not found")
}
if (fileMetadata.user_id !== profile.user_id) {
throw new Error("Unauthorized")
}
const { data: file, error: fileError } = await supabaseAdmin.storage
.from("files")
.download(fileMetadata.file_path)
if (fileError)
throw new Error(`Failed to retrieve file: ${fileError.message}`)
const fileBuffer = Buffer.from(await file.arrayBuffer())
const blob = new Blob([fileBuffer])
const fileExtension = fileMetadata.name.split(".").pop()?.toLowerCase()
if (embeddingsProvider === "openai") {
try {
if (profile.use_azure_openai) {
checkApiKey(profile.azure_openai_api_key, "Azure OpenAI")
} else {
checkApiKey(profile.openai_api_key, "OpenAI")
}
} catch (error: any) {
error.message =
error.message +
", make sure it is configured or else use local embeddings"
throw error
}
}
let chunks: FileItemChunk[] = []
switch (fileExtension) {
case "csv":
chunks = await processCSV(blob)
break
case "json":
chunks = await processJSON(blob)
break
case "md":
chunks = await processMarkdown(blob)
break
case "pdf":
chunks = await processPdf(blob)
break
case "txt":
chunks = await processTxt(blob)
break
default:
return new NextResponse("Unsupported file type", {
status: 400
})
}
let embeddings: any = []
let openai
if (profile.use_azure_openai) {
openai = new OpenAI({
apiKey: profile.azure_openai_api_key || "",
baseURL: `${profile.azure_openai_endpoint}/openai/deployments/${profile.azure_openai_embeddings_id}`,
defaultQuery: { "api-version": "2023-12-01-preview" },
defaultHeaders: { "api-key": profile.azure_openai_api_key }
})
} else {
openai = new OpenAI({
apiKey: profile.openai_api_key || "",
organization: profile.openai_organization_id
})
}
if (embeddingsProvider === "openai") {
const response = await openai.embeddings.create({
model: "text-embedding-3-small",
input: chunks.map(chunk => chunk.content)
})
embeddings = response.data.map((item: any) => {
return item.embedding
})
} else if (embeddingsProvider === "local") {
const embeddingPromises = chunks.map(async chunk => {
try {
return await generateLocalEmbedding(chunk.content)
} catch (error) {
console.error(`Error generating embedding for chunk: ${chunk}`, error)
return null
}
})
embeddings = await Promise.all(embeddingPromises)
}
const file_items = chunks.map((chunk, index) => ({
file_id,
user_id: profile.user_id,
content: chunk.content,
tokens: chunk.tokens,
openai_embedding:
embeddingsProvider === "openai"
? ((embeddings[index] || null) as any)
: null,
local_embedding:
embeddingsProvider === "local"
? ((embeddings[index] || null) as any)
: null
}))
await supabaseAdmin.from("file_items").upsert(file_items)
const totalTokens = file_items.reduce((acc, item) => acc + item.tokens, 0)
await supabaseAdmin
.from("files")
.update({ tokens: totalTokens })
.eq("id", file_id)
return new NextResponse("Embed Successful", {
status: 200
})
} catch (error: any) {
console.log(`Error in retrieval/process: ${error.stack}`)
const errorMessage = error?.message || "An unexpected error occurred"
const errorCode = error.status || 500
return new Response(JSON.stringify({ message: errorMessage }), {
status: errorCode
})
}
}

View File

@ -0,0 +1,102 @@
import { generateLocalEmbedding } from "@/lib/generate-local-embedding"
import { checkApiKey, getServerProfile } from "@/lib/server/server-chat-helpers"
import { Database } from "@/supabase/types"
import { createClient } from "@supabase/supabase-js"
import OpenAI from "openai"
export async function POST(request: Request) {
const json = await request.json()
const { userInput, fileIds, embeddingsProvider, sourceCount } = json as {
userInput: string
fileIds: string[]
embeddingsProvider: "openai" | "local"
sourceCount: number
}
const uniqueFileIds = [...new Set(fileIds)]
try {
const supabaseAdmin = createClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
const profile = await getServerProfile()
if (embeddingsProvider === "openai") {
if (profile.use_azure_openai) {
checkApiKey(profile.azure_openai_api_key, "Azure OpenAI")
} else {
checkApiKey(profile.openai_api_key, "OpenAI")
}
}
let chunks: any[] = []
let openai
if (profile.use_azure_openai) {
openai = new OpenAI({
apiKey: profile.azure_openai_api_key || "",
baseURL: `${profile.azure_openai_endpoint}/openai/deployments/${profile.azure_openai_embeddings_id}`,
defaultQuery: { "api-version": "2023-12-01-preview" },
defaultHeaders: { "api-key": profile.azure_openai_api_key }
})
} else {
openai = new OpenAI({
apiKey: profile.openai_api_key || "",
organization: profile.openai_organization_id
})
}
if (embeddingsProvider === "openai") {
const response = await openai.embeddings.create({
model: "text-embedding-3-small",
input: userInput
})
const openaiEmbedding = response.data.map(item => item.embedding)[0]
const { data: openaiFileItems, error: openaiError } =
await supabaseAdmin.rpc("match_file_items_openai", {
query_embedding: openaiEmbedding as any,
match_count: sourceCount,
file_ids: uniqueFileIds
})
if (openaiError) {
throw openaiError
}
chunks = openaiFileItems
} else if (embeddingsProvider === "local") {
const localEmbedding = await generateLocalEmbedding(userInput)
const { data: localFileItems, error: localFileItemsError } =
await supabaseAdmin.rpc("match_file_items_local", {
query_embedding: localEmbedding as any,
match_count: sourceCount,
file_ids: uniqueFileIds
})
if (localFileItemsError) {
throw localFileItemsError
}
chunks = localFileItems
}
const mostSimilarChunks = chunks?.sort(
(a, b) => b.similarity - a.similarity
)
return new Response(JSON.stringify({ results: mostSimilarChunks }), {
status: 200
})
} catch (error: any) {
const errorMessage = error.error?.message || "An unexpected error occurred"
const errorCode = error.status || 500
return new Response(JSON.stringify({ message: errorMessage }), {
status: errorCode
})
}
}

View File

@ -0,0 +1,37 @@
import { Database } from "@/supabase/types"
import { createClient } from "@supabase/supabase-js"
export const runtime = "edge"
export async function POST(request: Request) {
const json = await request.json()
const { username } = json as {
username: string
}
try {
const supabaseAdmin = createClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
const { data: usernames, error } = await supabaseAdmin
.from("profiles")
.select("username")
.eq("username", username)
if (!usernames) {
throw new Error(error.message)
}
return new Response(JSON.stringify({ isAvailable: !usernames.length }), {
status: 200
})
} catch (error: any) {
const errorMessage = error.error?.message || "An unexpected error occurred"
const errorCode = error.status || 500
return new Response(JSON.stringify({ message: errorMessage }), {
status: errorCode
})
}
}

View File

@ -0,0 +1,38 @@
import { Database } from "@/supabase/types"
import { createClient } from "@supabase/supabase-js"
export const runtime = "edge"
export async function POST(request: Request) {
const json = await request.json()
const { userId } = json as {
userId: string
}
try {
const supabaseAdmin = createClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
const { data, error } = await supabaseAdmin
.from("profiles")
.select("username")
.eq("user_id", userId)
.single()
if (!data) {
throw new Error(error.message)
}
return new Response(JSON.stringify({ username: data.username }), {
status: 200
})
} catch (error: any) {
const errorMessage = error.error?.message || "An unexpected error occurred"
const errorCode = error.status || 500
return new Response(JSON.stringify({ message: errorMessage }), {
status: errorCode
})
}
}

View File

@ -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)
}
}

View File

@ -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"
}
}

View File

@ -0,0 +1,128 @@
import { ChatbotUIContext } from "@/context/context"
import { Tables } from "@/supabase/types"
import { IconRobotFace } from "@tabler/icons-react"
import Image from "next/image"
import { FC, useContext, useEffect, useRef } from "react"
import { usePromptAndCommand } from "./chat-hooks/use-prompt-and-command"
interface AssistantPickerProps {}
export const AssistantPicker: FC<AssistantPickerProps> = ({}) => {
const {
assistants,
assistantImages,
focusAssistant,
atCommand,
isAssistantPickerOpen,
setIsAssistantPickerOpen
} = useContext(ChatbotUIContext)
const { handleSelectAssistant } = usePromptAndCommand()
const itemsRef = useRef<(HTMLDivElement | null)[]>([])
useEffect(() => {
if (focusAssistant && itemsRef.current[0]) {
itemsRef.current[0].focus()
}
}, [focusAssistant])
const filteredAssistants = assistants.filter(assistant =>
assistant.name.toLowerCase().includes(atCommand.toLowerCase())
)
const handleOpenChange = (isOpen: boolean) => {
setIsAssistantPickerOpen(isOpen)
}
const callSelectAssistant = (assistant: Tables<"assistants">) => {
handleSelectAssistant(assistant)
handleOpenChange(false)
}
const getKeyDownHandler =
(index: number) => (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Backspace") {
e.preventDefault()
handleOpenChange(false)
} else if (e.key === "Enter") {
e.preventDefault()
callSelectAssistant(filteredAssistants[index])
} else if (
(e.key === "Tab" || e.key === "ArrowDown") &&
!e.shiftKey &&
index === filteredAssistants.length - 1
) {
e.preventDefault()
itemsRef.current[0]?.focus()
} else if (e.key === "ArrowUp" && !e.shiftKey && index === 0) {
// go to last element if arrow up is pressed on first element
e.preventDefault()
itemsRef.current[itemsRef.current.length - 1]?.focus()
} else if (e.key === "ArrowUp") {
e.preventDefault()
const prevIndex =
index - 1 >= 0 ? index - 1 : itemsRef.current.length - 1
itemsRef.current[prevIndex]?.focus()
} else if (e.key === "ArrowDown") {
e.preventDefault()
const nextIndex = index + 1 < itemsRef.current.length ? index + 1 : 0
itemsRef.current[nextIndex]?.focus()
}
}
return (
<>
{isAssistantPickerOpen && (
<div className="bg-background flex flex-col space-y-1 rounded-xl border-2 p-2 text-sm">
{filteredAssistants.length === 0 ? (
<div className="text-md flex h-14 cursor-pointer items-center justify-center italic hover:opacity-50">
No matching assistants.
</div>
) : (
<>
{filteredAssistants.map((item, index) => (
<div
key={item.id}
ref={ref => {
itemsRef.current[index] = ref
}}
tabIndex={0}
className="hover:bg-accent focus:bg-accent flex cursor-pointer items-center rounded p-2 focus:outline-none"
onClick={() =>
callSelectAssistant(item as Tables<"assistants">)
}
onKeyDown={getKeyDownHandler(index)}
>
{item.image_path ? (
<Image
src={
assistantImages.find(
image => image.path === item.image_path
)?.url || ""
}
alt={item.name}
width={32}
height={32}
className="rounded"
/>
) : (
<IconRobotFace size={32} />
)}
<div className="ml-3 flex flex-col">
<div className="font-bold">{item.name}</div>
<div className="truncate text-sm opacity-80">
{item.description || "No description."}
</div>
</div>
</div>
))}
</>
)}
</div>
)}
</>
)
}

View File

@ -0,0 +1,48 @@
import { ChatbotUIContext } from "@/context/context"
import { FC, useContext } from "react"
import { AssistantPicker } from "./assistant-picker"
import { usePromptAndCommand } from "./chat-hooks/use-prompt-and-command"
import { FilePicker } from "./file-picker"
import { PromptPicker } from "./prompt-picker"
import { ToolPicker } from "./tool-picker"
interface ChatCommandInputProps {}
export const ChatCommandInput: FC<ChatCommandInputProps> = ({}) => {
const {
newMessageFiles,
chatFiles,
slashCommand,
isFilePickerOpen,
setIsFilePickerOpen,
hashtagCommand,
focusPrompt,
focusFile
} = useContext(ChatbotUIContext)
const { handleSelectUserFile, handleSelectUserCollection } =
usePromptAndCommand()
return (
<>
<PromptPicker />
<FilePicker
isOpen={isFilePickerOpen}
searchQuery={hashtagCommand}
onOpenChange={setIsFilePickerOpen}
selectedFileIds={[...newMessageFiles, ...chatFiles].map(
file => file.id
)}
selectedCollectionIds={[]}
onSelectFile={handleSelectUserFile}
onSelectCollection={handleSelectUserCollection}
isFocused={focusFile}
/>
<ToolPicker />
<AssistantPicker />
</>
)
}

View File

@ -0,0 +1,283 @@
import { ChatbotUIContext } from "@/context/context"
import { getFileFromStorage } from "@/db/storage/files"
import useHotkey from "@/lib/hooks/use-hotkey"
import { cn } from "@/lib/utils"
import { ChatFile, MessageImage } from "@/types"
import {
IconCircleFilled,
IconFileFilled,
IconFileTypeCsv,
IconFileTypeDocx,
IconFileTypePdf,
IconFileTypeTxt,
IconJson,
IconLoader2,
IconMarkdown,
IconX
} from "@tabler/icons-react"
import Image from "next/image"
import { FC, useContext, useState } from "react"
import { Button } from "../ui/button"
import { FilePreview } from "../ui/file-preview"
import { WithTooltip } from "../ui/with-tooltip"
import { ChatRetrievalSettings } from "./chat-retrieval-settings"
interface ChatFilesDisplayProps {}
export const ChatFilesDisplay: FC<ChatFilesDisplayProps> = ({}) => {
useHotkey("f", () => setShowFilesDisplay(prev => !prev))
useHotkey("e", () => setUseRetrieval(prev => !prev))
const {
files,
newMessageImages,
setNewMessageImages,
newMessageFiles,
setNewMessageFiles,
setShowFilesDisplay,
showFilesDisplay,
chatFiles,
chatImages,
setChatImages,
setChatFiles,
setUseRetrieval
} = useContext(ChatbotUIContext)
const [selectedFile, setSelectedFile] = useState<ChatFile | null>(null)
const [selectedImage, setSelectedImage] = useState<MessageImage | null>(null)
const [showPreview, setShowPreview] = useState(false)
const messageImages = [
...newMessageImages.filter(
image =>
!chatImages.some(chatImage => chatImage.messageId === image.messageId)
)
]
const combinedChatFiles = [
...newMessageFiles.filter(
file => !chatFiles.some(chatFile => chatFile.id === file.id)
),
...chatFiles
]
const combinedMessageFiles = [...messageImages, ...combinedChatFiles]
const getLinkAndView = async (file: ChatFile) => {
const fileRecord = files.find(f => f.id === file.id)
if (!fileRecord) return
const link = await getFileFromStorage(fileRecord.file_path)
window.open(link, "_blank")
}
return showFilesDisplay && combinedMessageFiles.length > 0 ? (
<>
{showPreview && selectedImage && (
<FilePreview
type="image"
item={selectedImage}
isOpen={showPreview}
onOpenChange={(isOpen: boolean) => {
setShowPreview(isOpen)
setSelectedImage(null)
}}
/>
)}
{showPreview && selectedFile && (
<FilePreview
type="file"
item={selectedFile}
isOpen={showPreview}
onOpenChange={(isOpen: boolean) => {
setShowPreview(isOpen)
setSelectedFile(null)
}}
/>
)}
<div className="space-y-2">
<div className="flex w-full items-center justify-center">
<Button
className="flex h-[32px] w-[140px] space-x-2"
onClick={() => setShowFilesDisplay(false)}
>
<RetrievalToggle />
<div>Hide files</div>
<div onClick={e => e.stopPropagation()}>
<ChatRetrievalSettings />
</div>
</Button>
</div>
<div className="overflow-auto">
<div className="flex gap-2 overflow-auto pt-2">
{messageImages.map((image, index) => (
<div
key={index}
className="relative flex h-[64px] cursor-pointer items-center space-x-4 rounded-xl hover:opacity-50"
>
<Image
className="rounded"
// Force the image to be 56px by 56px
style={{
minWidth: "56px",
minHeight: "56px",
maxHeight: "56px",
maxWidth: "56px"
}}
src={image.base64} // Preview images will always be base64
alt="File image"
width={56}
height={56}
onClick={() => {
setSelectedImage(image)
setShowPreview(true)
}}
/>
<IconX
className="bg-muted-foreground border-primary absolute right-[-6px] top-[-2px] flex size-5 cursor-pointer items-center justify-center rounded-full border-DEFAULT text-[10px] hover:border-red-500 hover:bg-white hover:text-red-500"
onClick={e => {
e.stopPropagation()
setNewMessageImages(
newMessageImages.filter(
f => f.messageId !== image.messageId
)
)
setChatImages(
chatImages.filter(f => f.messageId !== image.messageId)
)
}}
/>
</div>
))}
{combinedChatFiles.map((file, index) =>
file.id === "loading" ? (
<div
key={index}
className="relative flex h-[64px] items-center space-x-4 rounded-xl border-2 px-4 py-3"
>
<div className="rounded bg-blue-500 p-2">
<IconLoader2 className="animate-spin" />
</div>
<div className="truncate text-sm">
<div className="truncate">{file.name}</div>
<div className="truncate opacity-50">{file.type}</div>
</div>
</div>
) : (
<div
key={file.id}
className="relative flex h-[64px] cursor-pointer items-center space-x-4 rounded-xl border-2 px-4 py-3 hover:opacity-50"
onClick={() => getLinkAndView(file)}
>
<div className="rounded bg-blue-500 p-2">
{(() => {
let fileExtension = file.type.includes("/")
? file.type.split("/")[1]
: file.type
switch (fileExtension) {
case "pdf":
return <IconFileTypePdf />
case "markdown":
return <IconMarkdown />
case "txt":
return <IconFileTypeTxt />
case "json":
return <IconJson />
case "csv":
return <IconFileTypeCsv />
case "docx":
return <IconFileTypeDocx />
default:
return <IconFileFilled />
}
})()}
</div>
<div className="truncate text-sm">
<div className="truncate">{file.name}</div>
</div>
<IconX
className="bg-muted-foreground border-primary absolute right-[-6px] top-[-6px] flex size-5 cursor-pointer items-center justify-center rounded-full border-DEFAULT text-[10px] hover:border-red-500 hover:bg-white hover:text-red-500"
onClick={e => {
e.stopPropagation()
setNewMessageFiles(
newMessageFiles.filter(f => f.id !== file.id)
)
setChatFiles(chatFiles.filter(f => f.id !== file.id))
}}
/>
</div>
)
)}
</div>
</div>
</div>
</>
) : (
combinedMessageFiles.length > 0 && (
<div className="flex w-full items-center justify-center space-x-2">
<Button
className="flex h-[32px] w-[140px] space-x-2"
onClick={() => setShowFilesDisplay(true)}
>
<RetrievalToggle />
<div>
{" "}
View {combinedMessageFiles.length} file
{combinedMessageFiles.length > 1 ? "s" : ""}
</div>
<div onClick={e => e.stopPropagation()}>
<ChatRetrievalSettings />
</div>
</Button>
</div>
)
)
}
const RetrievalToggle = ({}) => {
const { useRetrieval, setUseRetrieval } = useContext(ChatbotUIContext)
return (
<div className="flex items-center">
<WithTooltip
delayDuration={0}
side="top"
display={
<div>
{useRetrieval
? "File retrieval is enabled on the selected files for this message. Click the indicator to disable."
: "Click the indicator to enable file retrieval for this message."}
</div>
}
trigger={
<IconCircleFilled
className={cn(
"p-1",
useRetrieval ? "text-green-500" : "text-red-500",
useRetrieval ? "hover:text-green-200" : "hover:text-red-200"
)}
size={24}
onClick={e => {
e.stopPropagation()
setUseRetrieval(prev => !prev)
}}
/>
}
/>
</div>
)
}

View File

@ -0,0 +1,213 @@
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"
import { useTranslation } from 'react-i18next'
interface ChatHelpProps {}
export const ChatHelp: FC<ChatHelpProps> = ({}) => {
const { t } = useTranslation()
useHotkey("/", () => setIsOpen(prevState => !prevState))
const [isOpen, setIsOpen] = useState(false)
return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<IconQuestionMark className="bg-primary text-secondary size-[24px] cursor-pointer rounded-full p-0.5 opacity-60 hover:opacity-50 lg:size-[30px] lg:p-1" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel className="flex items-center justify-between">
<div className="flex space-x-2">
<Link
className="cursor-pointer hover:opacity-50"
href="https://x.com/JellyDropsLLC"
target="_blank"
rel="noopener noreferrer"
>
<IconBrandX />
</Link>
<Link
className="cursor-pointer hover:opacity-50"
href="https://github.com/JellyDropsLLC"
target="_blank"
rel="noopener noreferrer"
>
<IconBrandGithub />
</Link>
</div>
<div className="flex space-x-2">
<Announcements />
<Link
className="cursor-pointer hover:opacity-50"
href="/help"
target="_blank"
rel="noopener noreferrer"
>
<IconHelpCircle size={24} />
</Link>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem className="flex justify-between">
<div>{t("help.showHelp")}</div>
<div className="flex opacity-60">
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
</div>
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
Shift
</div>
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
/
</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem className="flex justify-between">
<div>{t("help.showWorkspaces")}</div>
<div className="flex opacity-60">
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
</div>
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
Shift
</div>
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
;
</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem className="flex w-[300px] justify-between">
<div>{t("help.newChat")}</div>
<div className="flex opacity-60">
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
</div>
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
Shift
</div>
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
O
</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem className="flex justify-between">
<div>{t("help.focusChat")}</div>
<div className="flex opacity-60">
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
</div>
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
Shift
</div>
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
L
</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem className="flex justify-between">
<div>{t("help.toggleFiles")}</div>
<div className="flex opacity-60">
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
</div>
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
Shift
</div>
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
F
</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem className="flex justify-between">
<div>{t("help.toggleRetrieval")}</div>
<div className="flex opacity-60">
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
</div>
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
Shift
</div>
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
E
</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem className="flex justify-between">
<div>{t("help.openSettings")}</div>
<div className="flex opacity-60">
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
</div>
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
Shift
</div>
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
I
</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem className="flex justify-between">
<div>{t("help.openQuickSettings")}</div>
<div className="flex opacity-60">
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
</div>
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
Shift
</div>
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
P
</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem className="flex justify-between">
<div>{t("help.toggleSidebar")}</div>
<div className="flex opacity-60">
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
</div>
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
Shift
</div>
<div className="min-w-[30px] rounded border-DEFAULT p-1 text-center">
S
</div>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -0,0 +1,511 @@
// Only used in use-chat-handler.tsx to keep it clean
import { createChatFiles } from "@/db/chat-files"
import { createChat } from "@/db/chats"
import { createMessageFileItems } from "@/db/message-file-items"
import { createMessages, updateMessage } from "@/db/messages"
import { uploadMessageImage } from "@/db/storage/message-images"
import {
buildFinalMessages,
adaptMessagesForGoogleGemini
} from "@/lib/build-prompt"
import { consumeReadableStream } from "@/lib/consume-stream"
import { Tables, TablesInsert } from "@/supabase/types"
import {
ChatFile,
ChatMessage,
ChatPayload,
ChatSettings,
LLM,
MessageImage
} from "@/types"
import React from "react"
import { toast } from "sonner"
import { v4 as uuidv4 } from "uuid"
export const validateChatSettings = (
chatSettings: ChatSettings | null,
modelData: LLM | undefined,
profile: Tables<"profiles"> | null,
selectedWorkspace: Tables<"workspaces"> | null,
messageContent: string
) => {
if (!chatSettings) {
throw new Error("Chat settings not found")
}
if (!modelData) {
throw new Error("Model not found")
}
if (!profile) {
throw new Error("Profile not found")
}
if (!selectedWorkspace) {
throw new Error("Workspace not found")
}
if (!messageContent) {
throw new Error("Message content not found")
}
}
export const handleRetrieval = async (
userInput: string,
newMessageFiles: ChatFile[],
chatFiles: ChatFile[],
embeddingsProvider: "openai" | "local",
sourceCount: number
) => {
const response = await fetch("/api/retrieval/retrieve", {
method: "POST",
body: JSON.stringify({
userInput,
fileIds: [...newMessageFiles, ...chatFiles].map(file => file.id),
embeddingsProvider,
sourceCount
})
})
if (!response.ok) {
console.error("Error retrieving:", response)
}
const { results } = (await response.json()) as {
results: Tables<"file_items">[]
}
return results
}
export const createTempMessages = (
messageContent: string,
chatMessages: ChatMessage[],
chatSettings: ChatSettings,
b64Images: string[],
isRegeneration: boolean,
setChatMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>,
selectedAssistant: Tables<"assistants"> | null
) => {
let tempUserChatMessage: ChatMessage = {
message: {
chat_id: "",
assistant_id: null,
content: messageContent,
created_at: "",
id: uuidv4(),
image_paths: b64Images,
model: chatSettings.model,
role: "user",
sequence_number: chatMessages.length,
updated_at: "",
user_id: ""
},
fileItems: []
}
let tempAssistantChatMessage: ChatMessage = {
message: {
chat_id: "",
assistant_id: selectedAssistant?.id || null,
content: "",
created_at: "",
id: uuidv4(),
image_paths: [],
model: chatSettings.model,
role: "assistant",
sequence_number: chatMessages.length + 1,
updated_at: "",
user_id: ""
},
fileItems: []
}
let newMessages = []
if (isRegeneration) {
const lastMessageIndex = chatMessages.length - 1
chatMessages[lastMessageIndex].message.content = ""
newMessages = [...chatMessages]
} else {
newMessages = [
...chatMessages,
tempUserChatMessage,
tempAssistantChatMessage
]
}
setChatMessages(newMessages)
return {
tempUserChatMessage,
tempAssistantChatMessage
}
}
export const handleLocalChat = async (
payload: ChatPayload,
profile: Tables<"profiles">,
chatSettings: ChatSettings,
tempAssistantMessage: ChatMessage,
isRegeneration: boolean,
newAbortController: AbortController,
setIsGenerating: React.Dispatch<React.SetStateAction<boolean>>,
setFirstTokenReceived: React.Dispatch<React.SetStateAction<boolean>>,
setChatMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>,
setToolInUse: React.Dispatch<React.SetStateAction<string>>
) => {
const formattedMessages = await buildFinalMessages(payload, profile, [])
// Ollama API: https://github.com/jmorganca/ollama/blob/main/docs/api.md
const response = await fetchChatResponse(
process.env.NEXT_PUBLIC_OLLAMA_URL + "/api/chat",
{
model: chatSettings.model,
messages: formattedMessages,
options: {
temperature: payload.chatSettings.temperature
}
},
false,
newAbortController,
setIsGenerating,
setChatMessages
)
return await processResponse(
response,
isRegeneration
? payload.chatMessages[payload.chatMessages.length - 1]
: tempAssistantMessage,
false,
newAbortController,
setFirstTokenReceived,
setChatMessages,
setToolInUse
)
}
export const handleHostedChat = async (
payload: ChatPayload,
profile: Tables<"profiles">,
modelData: LLM,
tempAssistantChatMessage: ChatMessage,
isRegeneration: boolean,
newAbortController: AbortController,
newMessageImages: MessageImage[],
chatImages: MessageImage[],
setIsGenerating: React.Dispatch<React.SetStateAction<boolean>>,
setFirstTokenReceived: React.Dispatch<React.SetStateAction<boolean>>,
setChatMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>,
setToolInUse: React.Dispatch<React.SetStateAction<string>>
) => {
const provider =
modelData.provider === "openai" && profile.use_azure_openai
? "azure"
: modelData.provider
let draftMessages = await buildFinalMessages(payload, profile, chatImages)
let formattedMessages : any[] = []
if (provider === "google") {
formattedMessages = await adaptMessagesForGoogleGemini(payload, draftMessages)
} else {
formattedMessages = draftMessages
}
const apiEndpoint =
provider === "custom" ? "/api/chat/custom" : `/api/chat/${provider}`
const requestBody = {
chatSettings: payload.chatSettings,
messages: formattedMessages,
customModelId: provider === "custom" ? modelData.hostedId : ""
}
const response = await fetchChatResponse(
apiEndpoint,
requestBody,
true,
newAbortController,
setIsGenerating,
setChatMessages
)
return await processResponse(
response,
isRegeneration
? payload.chatMessages[payload.chatMessages.length - 1]
: tempAssistantChatMessage,
true,
newAbortController,
setFirstTokenReceived,
setChatMessages,
setToolInUse
)
}
export const fetchChatResponse = async (
url: string,
body: object,
isHosted: boolean,
controller: AbortController,
setIsGenerating: React.Dispatch<React.SetStateAction<boolean>>,
setChatMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>
) => {
const response = await fetch(url, {
method: "POST",
body: JSON.stringify(body),
signal: controller.signal
})
if (!response.ok) {
if (response.status === 404 && !isHosted) {
toast.error(
"Model not found. Make sure you have it downloaded via Ollama."
)
}
const errorData = await response.json()
toast.error(errorData.message)
setIsGenerating(false)
setChatMessages(prevMessages => prevMessages.slice(0, -2))
}
return response
}
export const processResponse = async (
response: Response,
lastChatMessage: ChatMessage,
isHosted: boolean,
controller: AbortController,
setFirstTokenReceived: React.Dispatch<React.SetStateAction<boolean>>,
setChatMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>,
setToolInUse: React.Dispatch<React.SetStateAction<string>>
) => {
let fullText = ""
let contentToAdd = ""
if (response.body) {
await consumeReadableStream(
response.body,
chunk => {
setFirstTokenReceived(true)
setToolInUse("none")
try {
contentToAdd = isHosted
? chunk
: // Ollama's streaming endpoint returns new-line separated JSON
// objects. A chunk may have more than one of these objects, so we
// need to split the chunk by new-lines and handle each one
// separately.
chunk
.trimEnd()
.split("\n")
.reduce(
(acc, line) => acc + JSON.parse(line).message.content,
""
)
fullText += contentToAdd
} catch (error) {
console.error("Error parsing JSON:", error)
}
setChatMessages(prev =>
prev.map(chatMessage => {
if (chatMessage.message.id === lastChatMessage.message.id) {
const updatedChatMessage: ChatMessage = {
message: {
...chatMessage.message,
content: fullText
},
fileItems: chatMessage.fileItems
}
return updatedChatMessage
}
return chatMessage
})
)
},
controller.signal
)
return fullText
} else {
throw new Error("Response body is null")
}
}
export const handleCreateChat = async (
chatSettings: ChatSettings,
profile: Tables<"profiles">,
selectedWorkspace: Tables<"workspaces">,
messageContent: string,
selectedAssistant: Tables<"assistants">,
newMessageFiles: ChatFile[],
setSelectedChat: React.Dispatch<React.SetStateAction<Tables<"chats"> | null>>,
setChats: React.Dispatch<React.SetStateAction<Tables<"chats">[]>>,
setChatFiles: React.Dispatch<React.SetStateAction<ChatFile[]>>
) => {
const createdChat = await createChat({
user_id: profile.user_id,
workspace_id: selectedWorkspace.id,
assistant_id: selectedAssistant?.id || null,
context_length: chatSettings.contextLength,
include_profile_context: chatSettings.includeProfileContext,
include_workspace_instructions: chatSettings.includeWorkspaceInstructions,
model: chatSettings.model,
name: messageContent.substring(0, 100),
prompt: chatSettings.prompt,
temperature: chatSettings.temperature,
embeddings_provider: chatSettings.embeddingsProvider
})
setSelectedChat(createdChat)
setChats(chats => [createdChat, ...chats])
await createChatFiles(
newMessageFiles.map(file => ({
user_id: profile.user_id,
chat_id: createdChat.id,
file_id: file.id
}))
)
setChatFiles(prev => [...prev, ...newMessageFiles])
return createdChat
}
export const handleCreateMessages = async (
chatMessages: ChatMessage[],
currentChat: Tables<"chats">,
profile: Tables<"profiles">,
modelData: LLM,
messageContent: string,
generatedText: string,
newMessageImages: MessageImage[],
isRegeneration: boolean,
retrievedFileItems: Tables<"file_items">[],
setChatMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>,
setChatFileItems: React.Dispatch<
React.SetStateAction<Tables<"file_items">[]>
>,
setChatImages: React.Dispatch<React.SetStateAction<MessageImage[]>>,
selectedAssistant: Tables<"assistants"> | null
) => {
const finalUserMessage: TablesInsert<"messages"> = {
chat_id: currentChat.id,
assistant_id: null,
user_id: profile.user_id,
content: messageContent,
model: modelData.modelId,
role: "user",
sequence_number: chatMessages.length,
image_paths: []
}
const finalAssistantMessage: TablesInsert<"messages"> = {
chat_id: currentChat.id,
assistant_id: selectedAssistant?.id || null,
user_id: profile.user_id,
content: generatedText,
model: modelData.modelId,
role: "assistant",
sequence_number: chatMessages.length + 1,
image_paths: []
}
let finalChatMessages: ChatMessage[] = []
if (isRegeneration) {
const lastStartingMessage = chatMessages[chatMessages.length - 1].message
const updatedMessage = await updateMessage(lastStartingMessage.id, {
...lastStartingMessage,
content: generatedText
})
chatMessages[chatMessages.length - 1].message = updatedMessage
finalChatMessages = [...chatMessages]
setChatMessages(finalChatMessages)
} else {
const createdMessages = await createMessages([
finalUserMessage,
finalAssistantMessage
])
// Upload each image (stored in newMessageImages) for the user message to message_images bucket
const uploadPromises = newMessageImages
.filter(obj => obj.file !== null)
.map(obj => {
let filePath = `${profile.user_id}/${currentChat.id}/${
createdMessages[0].id
}/${uuidv4()}`
return uploadMessageImage(filePath, obj.file as File).catch(error => {
console.error(`Failed to upload image at ${filePath}:`, error)
return null
})
})
const paths = (await Promise.all(uploadPromises)).filter(
Boolean
) as string[]
setChatImages(prevImages => [
...prevImages,
...newMessageImages.map((obj, index) => ({
...obj,
messageId: createdMessages[0].id,
path: paths[index]
}))
])
const updatedMessage = await updateMessage(createdMessages[0].id, {
...createdMessages[0],
image_paths: paths
})
const createdMessageFileItems = await createMessageFileItems(
retrievedFileItems.map(fileItem => {
return {
user_id: profile.user_id,
message_id: createdMessages[1].id,
file_item_id: fileItem.id
}
})
)
finalChatMessages = [
...chatMessages,
{
message: updatedMessage,
fileItems: []
},
{
message: createdMessages[1],
fileItems: retrievedFileItems.map(fileItem => fileItem.id)
}
]
setChatFileItems(prevFileItems => {
const newFileItems = retrievedFileItems.filter(
fileItem => !prevFileItems.some(prevItem => prevItem.id === fileItem.id)
)
return [...prevFileItems, ...newFileItems]
})
setChatMessages(finalChatMessages)
}
}

View File

@ -0,0 +1,450 @@
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 i18nConfig from "@/i18nConfig"
import {
createTempMessages,
handleCreateChat,
handleCreateMessages,
handleHostedChat,
handleLocalChat,
handleRetrieval,
processResponse,
validateChatSettings
} from "../chat-helpers"
import { usePathname } from "next/navigation"
export const useChatHandler = () => {
const pathname = usePathname() // 获取当前路径
const router = useRouter()
// 提取当前路径中的 locale 部分
// const locale = pathname.split("/")[1] || "en"
const {
userInput,
chatFiles,
setUserInput,
setNewMessageImages,
profile,
setIsGenerating,
setChatMessages,
setFirstTokenReceived,
selectedChat,
selectedWorkspace,
setSelectedChat,
setChats,
setSelectedTools,
availableLocalModels,
availableOpenRouterModels,
abortController,
setAbortController,
chatSettings,
newMessageImages,
selectedAssistant,
chatMessages,
chatImages,
setChatImages,
setChatFiles,
setNewMessageFiles,
setShowFilesDisplay,
newMessageFiles,
chatFileItems,
setChatFileItems,
setToolInUse,
useRetrieval,
sourceCount,
setIsPromptPickerOpen,
setIsFilePickerOpen,
selectedTools,
selectedPreset,
setChatSettings,
models,
isPromptPickerOpen,
isFilePickerOpen,
isToolPickerOpen
} = useContext(ChatbotUIContext)
const chatInputRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
if (!isPromptPickerOpen || !isFilePickerOpen || !isToolPickerOpen) {
chatInputRef.current?.focus()
}
}, [isPromptPickerOpen, isFilePickerOpen, isToolPickerOpen])
const handleNewChat = async () => {
if (!selectedWorkspace) return
setUserInput("")
setChatMessages([])
setSelectedChat(null)
setChatFileItems([])
setIsGenerating(false)
setFirstTokenReceived(false)
setChatFiles([])
setChatImages([])
setNewMessageFiles([])
setNewMessageImages([])
setShowFilesDisplay(false)
setIsPromptPickerOpen(false)
setIsFilePickerOpen(false)
setSelectedTools([])
setToolInUse("none")
if (selectedAssistant) {
setChatSettings({
model: selectedAssistant.model as LLMID,
prompt: selectedAssistant.prompt,
temperature: selectedAssistant.temperature,
contextLength: selectedAssistant.context_length,
includeProfileContext: selectedAssistant.include_profile_context,
includeWorkspaceInstructions:
selectedAssistant.include_workspace_instructions,
embeddingsProvider: selectedAssistant.embeddings_provider as
| "openai"
| "local"
})
let allFiles = []
const assistantFiles = (
await getAssistantFilesByAssistantId(selectedAssistant.id)
).files
allFiles = [...assistantFiles]
const assistantCollections = (
await getAssistantCollectionsByAssistantId(selectedAssistant.id)
).collections
for (const collection of assistantCollections) {
const collectionFiles = (
await getCollectionFilesByCollectionId(collection.id)
).files
allFiles = [...allFiles, ...collectionFiles]
}
const assistantTools = (
await getAssistantToolsByAssistantId(selectedAssistant.id)
).tools
setSelectedTools(assistantTools)
setChatFiles(
allFiles.map(file => ({
id: file.id,
name: file.name,
type: file.type,
file: null
}))
)
if (allFiles.length > 0) setShowFilesDisplay(true)
} else if (selectedPreset) {
setChatSettings({
model: selectedPreset.model as LLMID,
prompt: selectedPreset.prompt,
temperature: selectedPreset.temperature,
contextLength: selectedPreset.context_length,
includeProfileContext: selectedPreset.include_profile_context,
includeWorkspaceInstructions:
selectedPreset.include_workspace_instructions,
embeddingsProvider: selectedPreset.embeddings_provider as
| "openai"
| "local"
})
} else if (selectedWorkspace) {
// setChatSettings({
// model: (selectedWorkspace.default_model ||
// "gpt-4-1106-preview") as LLMID,
// prompt:
// selectedWorkspace.default_prompt ||
// "You are a friendly, helpful AI assistant.",
// temperature: selectedWorkspace.default_temperature || 0.5,
// contextLength: selectedWorkspace.default_context_length || 4096,
// includeProfileContext:
// selectedWorkspace.include_profile_context || true,
// includeWorkspaceInstructions:
// selectedWorkspace.include_workspace_instructions || true,
// embeddingsProvider:
// (selectedWorkspace.embeddings_provider as "openai" | "local") ||
// "openai"
// })
}
const pathSegments = pathname.split("/").filter(Boolean)
const locales = i18nConfig.locales
const defaultLocale = i18nConfig.defaultLocale
let locale: (typeof locales)[number] = defaultLocale
const segment = pathSegments[0] as (typeof locales)[number]
if (locales.includes(segment)) {
locale = segment
}
// ✅ 正确构造 localePrefix不包含前导 /
const localePrefix = locale === defaultLocale ? "" : `/${locale}`
console.log("[use-chat-handler.tsx]...........localePrefix", localePrefix)
return router.push(`${localePrefix}/${selectedWorkspace.id}/chat`)
// return router.push(`/${locale}/${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
}
}

View File

@ -0,0 +1,77 @@
import { ChatbotUIContext } from "@/context/context"
import { useContext, useEffect, useState } from "react"
/**
* Custom hook for handling chat history in the chat component.
* It provides functions to set the new message content to the previous or next user message in the chat history.
*
* @returns An object containing the following functions:
* - setNewMessageContentToPreviousUserMessage: Sets the new message content to the previous user message.
* - setNewMessageContentToNextUserMessage: Sets the new message content to the next user message in the chat history.
*/
export const useChatHistoryHandler = () => {
const { setUserInput, chatMessages, isGenerating } =
useContext(ChatbotUIContext)
const userRoleString = "user"
const [messageHistoryIndex, setMessageHistoryIndex] = useState<number>(
chatMessages.length
)
useEffect(() => {
// If messages get deleted the history index pointed could be out of bounds
if (!isGenerating && messageHistoryIndex > chatMessages.length)
setMessageHistoryIndex(chatMessages.length)
}, [chatMessages, isGenerating, messageHistoryIndex])
/**
* Sets the new message content to the previous user message.
*/
const setNewMessageContentToPreviousUserMessage = () => {
let tempIndex = messageHistoryIndex
while (
tempIndex > 0 &&
chatMessages[tempIndex - 1].message.role !== userRoleString
) {
tempIndex--
}
const previousUserMessage =
chatMessages.length > 0 && tempIndex > 0
? chatMessages[tempIndex - 1]
: null
if (previousUserMessage) {
setUserInput(previousUserMessage.message.content)
setMessageHistoryIndex(tempIndex - 1)
}
}
/**
* Sets the new message content to the next user message in the chat history.
* If there is a next user message, it updates the user input and message history index accordingly.
* If there is no next user message, it resets the user input and sets the message history index to the end of the chat history.
*/
const setNewMessageContentToNextUserMessage = () => {
let tempIndex = messageHistoryIndex
while (
tempIndex < chatMessages.length - 1 &&
chatMessages[tempIndex + 1].message.role !== userRoleString
) {
tempIndex++
}
const nextUserMessage =
chatMessages.length > 0 && tempIndex < chatMessages.length - 1
? chatMessages[tempIndex + 1]
: null
setUserInput(nextUserMessage?.message.content || "")
setMessageHistoryIndex(
nextUserMessage ? tempIndex + 1 : chatMessages.length
)
}
return {
setNewMessageContentToPreviousUserMessage,
setNewMessageContentToNextUserMessage
}
}

View File

@ -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
}
}

View File

@ -0,0 +1,87 @@
import { ChatbotUIContext } from "@/context/context"
import {
type UIEventHandler,
useCallback,
useContext,
useEffect,
useRef,
useState
} from "react"
export const useScroll = () => {
const { isGenerating, chatMessages } = useContext(ChatbotUIContext)
const messagesStartRef = useRef<HTMLDivElement>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const isAutoScrolling = useRef(false)
const [isAtTop, setIsAtTop] = useState(false)
const [isAtBottom, setIsAtBottom] = useState(true)
const [userScrolled, setUserScrolled] = useState(false)
const [isOverflowing, setIsOverflowing] = useState(false)
useEffect(() => {
setUserScrolled(false)
if (!isGenerating && userScrolled) {
setUserScrolled(false)
}
}, [isGenerating])
useEffect(() => {
if (isGenerating && !userScrolled) {
scrollToBottom()
}
}, [chatMessages])
const handleScroll: UIEventHandler<HTMLDivElement> = useCallback(e => {
const target = e.target as HTMLDivElement
const bottom =
Math.round(target.scrollHeight) - Math.round(target.scrollTop) ===
Math.round(target.clientHeight)
setIsAtBottom(bottom)
const top = target.scrollTop === 0
setIsAtTop(top)
if (!bottom && !isAutoScrolling.current) {
setUserScrolled(true)
} else {
setUserScrolled(false)
}
const isOverflow = target.scrollHeight > target.clientHeight
setIsOverflowing(isOverflow)
}, [])
const scrollToTop = useCallback(() => {
if (messagesStartRef.current) {
messagesStartRef.current.scrollIntoView({ behavior: "instant" })
}
}, [])
const scrollToBottom = useCallback(() => {
isAutoScrolling.current = true
setTimeout(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "instant" })
}
isAutoScrolling.current = false
}, 100)
}, [])
return {
messagesStartRef,
messagesEndRef,
isAtTop,
isAtBottom,
userScrolled,
isOverflowing,
handleScroll,
scrollToTop,
scrollToBottom,
setIsAtBottom
}
}

View File

@ -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
}
}

View File

@ -0,0 +1,281 @@
import { ChatbotUIContext } from "@/context/context"
import useHotkey from "@/lib/hooks/use-hotkey"
import { LLM_LIST } from "@/lib/models/llm/llm-list"
import { cn } from "@/lib/utils"
import {
IconBolt,
IconCirclePlus,
IconPlayerStopFilled,
IconSend
} from "@tabler/icons-react"
import Image from "next/image"
import { FC, useContext, useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { Input } from "../ui/input"
import { TextareaAutosize } from "../ui/textarea-autosize"
import { ChatCommandInput } from "./chat-command-input"
import { ChatFilesDisplay } from "./chat-files-display"
import { useChatHandler } from "./chat-hooks/use-chat-handler"
import { useChatHistoryHandler } from "./chat-hooks/use-chat-history"
import { usePromptAndCommand } from "./chat-hooks/use-prompt-and-command"
import { useSelectFileHandler } from "./chat-hooks/use-select-file-handler"
interface ChatInputProps {}
export const ChatInput: FC<ChatInputProps> = ({}) => {
const { t } = useTranslation()
useHotkey("l", () => {
handleFocusChatInput()
})
const [isTyping, setIsTyping] = useState<boolean>(false)
const {
isAssistantPickerOpen,
focusAssistant,
setFocusAssistant,
userInput,
chatMessages,
isGenerating,
selectedPreset,
selectedAssistant,
focusPrompt,
setFocusPrompt,
focusFile,
focusTool,
setFocusTool,
isToolPickerOpen,
isPromptPickerOpen,
setIsPromptPickerOpen,
isFilePickerOpen,
setFocusFile,
chatSettings,
selectedTools,
setSelectedTools,
assistantImages
} = useContext(ChatbotUIContext)
const {
chatInputRef,
handleSendMessage,
handleStopMessage,
handleFocusChatInput
} = useChatHandler()
const { handleInputChange } = usePromptAndCommand()
const { filesToAccept, handleSelectDeviceFile } = useSelectFileHandler()
const {
setNewMessageContentToNextUserMessage,
setNewMessageContentToPreviousUserMessage
} = useChatHistoryHandler()
const fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
setTimeout(() => {
handleFocusChatInput()
}, 200) // FIX: hacky
}, [selectedPreset, selectedAssistant])
const handleKeyDown = (event: React.KeyboardEvent) => {
if (!isTyping && event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
setIsPromptPickerOpen(false)
handleSendMessage(userInput, chatMessages, false)
}
// Consolidate conditions to avoid TypeScript error
if (
isPromptPickerOpen ||
isFilePickerOpen ||
isToolPickerOpen ||
isAssistantPickerOpen
) {
if (
event.key === "Tab" ||
event.key === "ArrowUp" ||
event.key === "ArrowDown"
) {
event.preventDefault()
// Toggle focus based on picker type
if (isPromptPickerOpen) setFocusPrompt(!focusPrompt)
if (isFilePickerOpen) setFocusFile(!focusFile)
if (isToolPickerOpen) setFocusTool(!focusTool)
if (isAssistantPickerOpen) setFocusAssistant(!focusAssistant)
}
}
if (event.key === "ArrowUp" && event.shiftKey && event.ctrlKey) {
event.preventDefault()
setNewMessageContentToPreviousUserMessage()
}
if (event.key === "ArrowDown" && event.shiftKey && event.ctrlKey) {
event.preventDefault()
setNewMessageContentToNextUserMessage()
}
//use shift+ctrl+up and shift+ctrl+down to navigate through chat history
if (event.key === "ArrowUp" && event.shiftKey && event.ctrlKey) {
event.preventDefault()
setNewMessageContentToPreviousUserMessage()
}
if (event.key === "ArrowDown" && event.shiftKey && event.ctrlKey) {
event.preventDefault()
setNewMessageContentToNextUserMessage()
}
if (
isAssistantPickerOpen &&
(event.key === "Tab" ||
event.key === "ArrowUp" ||
event.key === "ArrowDown")
) {
event.preventDefault()
setFocusAssistant(!focusAssistant)
}
}
const handlePaste = (event: React.ClipboardEvent) => {
const imagesAllowed = LLM_LIST.find(
llm => llm.modelId === chatSettings?.model
)?.imageInput
const items = event.clipboardData.items
for (const item of items) {
if (item.type.indexOf("image") === 0) {
if (!imagesAllowed) {
toast.error(
`Images are not supported for this model. Use models like GPT-4 Vision instead.`
)
return
}
const file = item.getAsFile()
if (!file) return
handleSelectDeviceFile(file)
}
}
}
return (
<>
<div className="flex flex-col flex-wrap justify-center gap-2">
<ChatFilesDisplay />
{selectedTools &&
selectedTools.map((tool, index) => (
<div
key={index}
className="flex justify-center"
onClick={() =>
setSelectedTools(
selectedTools.filter(
selectedTool => selectedTool.id !== tool.id
)
)
}
>
<div className="flex cursor-pointer items-center justify-center space-x-1 rounded-lg bg-purple-600 px-3 py-1 hover:opacity-50">
<IconBolt size={20} />
<div>{tool.name}</div>
</div>
</div>
))}
{selectedAssistant && (
<div className="border-primary mx-auto flex w-fit items-center space-x-2 rounded-lg border p-1.5">
{selectedAssistant.image_path && (
<Image
className="rounded"
src={
assistantImages.find(
img => img.path === selectedAssistant.image_path
)?.base64
}
width={28}
height={28}
alt={selectedAssistant.name}
/>
)}
<div className="text-sm font-bold">
Talking to {selectedAssistant.name}
</div>
</div>
)}
</div>
<div className="border-input relative mt-3 flex min-h-[60px] w-full items-center justify-center rounded-xl border-2">
<div className="absolute bottom-[76px] left-0 max-h-[300px] w-full overflow-auto rounded-xl dark:border-none">
<ChatCommandInput />
</div>
<>
<IconCirclePlus
className="absolute bottom-[12px] left-3 cursor-pointer p-1 hover:opacity-50"
size={32}
onClick={() => fileInputRef.current?.click()}
/>
{/* Hidden input to select files from device */}
<Input
ref={fileInputRef}
className="hidden"
type="file"
onChange={e => {
if (!e.target.files) return
handleSelectDeviceFile(e.target.files[0])
}}
accept={filesToAccept}
/>
</>
<TextareaAutosize
textareaRef={chatInputRef}
className="ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring text-md flex w-full resize-none rounded-md border-none bg-transparent px-14 py-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
placeholder={t(
// `Ask anything. Type "@" for assistants, "/" for prompts, "#" for files, and "!" for tools.`
"inputPlaceholder"
)}
onValueChange={handleInputChange}
value={userInput}
minRows={1}
maxRows={18}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onCompositionStart={() => setIsTyping(true)}
onCompositionEnd={() => setIsTyping(false)}
/>
<div className="absolute bottom-[14px] right-3 cursor-pointer hover:opacity-50">
{isGenerating ? (
<IconPlayerStopFilled
className="hover:bg-background animate-pulse rounded bg-transparent p-1"
onClick={handleStopMessage}
size={30}
/>
) : (
<IconSend
className={cn(
"bg-primary text-secondary rounded p-1",
!userInput && "cursor-not-allowed opacity-50"
)}
onClick={() => {
if (!userInput) return
handleSendMessage(userInput, chatMessages, false)
}}
size={30}
/>
)}
</div>
</div>
</>
)
}

View File

@ -0,0 +1,38 @@
import { useChatHandler } from "@/components/chat/chat-hooks/use-chat-handler"
import { ChatbotUIContext } from "@/context/context"
import { Tables } from "@/supabase/types"
import { FC, useContext, useState } from "react"
import { Message } from "../messages/message"
interface ChatMessagesProps {}
export const ChatMessages: FC<ChatMessagesProps> = ({}) => {
const { chatMessages, chatFileItems } = useContext(ChatbotUIContext)
const { handleSendEdit } = useChatHandler()
const [editingMessage, setEditingMessage] = useState<Tables<"messages">>()
return chatMessages
.sort((a, b) => a.message.sequence_number - b.message.sequence_number)
.map((chatMessage, index, array) => {
const messageFileItems = chatFileItems.filter(
(chatFileItem, _, self) =>
chatMessage.fileItems.includes(chatFileItem.id) &&
self.findIndex(item => item.id === chatFileItem.id) === _
)
return (
<Message
key={chatMessage.message.sequence_number}
message={chatMessage.message}
fileItems={messageFileItems}
isEditing={editingMessage?.id === chatMessage.message.id}
isLast={index === array.length - 1}
onStartEdit={setEditingMessage}
onCancelEdit={() => setEditingMessage(undefined)}
onSubmitEdit={handleSendEdit}
/>
)
})
}

View File

@ -0,0 +1,65 @@
import { ChatbotUIContext } from "@/context/context"
import { IconAdjustmentsHorizontal } from "@tabler/icons-react"
import { FC, useContext, useState } from "react"
import { Button } from "../ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogTrigger
} from "../ui/dialog"
import { Label } from "../ui/label"
import { Slider } from "../ui/slider"
import { WithTooltip } from "../ui/with-tooltip"
interface ChatRetrievalSettingsProps {}
export const ChatRetrievalSettings: FC<ChatRetrievalSettingsProps> = ({}) => {
const { sourceCount, setSourceCount } = useContext(ChatbotUIContext)
const [isOpen, setIsOpen] = useState(false)
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger>
<WithTooltip
delayDuration={0}
side="top"
display={<div>Adjust retrieval settings.</div>}
trigger={
<IconAdjustmentsHorizontal
className="cursor-pointer pt-[4px] hover:opacity-50"
size={24}
/>
}
/>
</DialogTrigger>
<DialogContent>
<div className="space-y-3">
<Label className="flex items-center space-x-1">
<div>Source Count:</div>
<div>{sourceCount}</div>
</Label>
<Slider
value={[sourceCount]}
onValueChange={values => {
setSourceCount(values[0])
}}
min={1}
max={10}
step={1}
/>
</div>
<DialogFooter>
<Button size="sm" onClick={() => setIsOpen(false)}>
Save & Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,41 @@
import {
IconCircleArrowDownFilled,
IconCircleArrowUpFilled
} from "@tabler/icons-react"
import { FC } from "react"
interface ChatScrollButtonsProps {
isAtTop: boolean
isAtBottom: boolean
isOverflowing: boolean
scrollToTop: () => void
scrollToBottom: () => void
}
export const ChatScrollButtons: FC<ChatScrollButtonsProps> = ({
isAtTop,
isAtBottom,
isOverflowing,
scrollToTop,
scrollToBottom
}) => {
return (
<>
{!isAtTop && isOverflowing && (
<IconCircleArrowUpFilled
className="cursor-pointer opacity-50 hover:opacity-100"
size={32}
onClick={scrollToTop}
/>
)}
{!isAtBottom && isOverflowing && (
<IconCircleArrowDownFilled
className="cursor-pointer opacity-50 hover:opacity-100"
size={32}
onClick={scrollToBottom}
/>
)}
</>
)
}

View File

@ -0,0 +1,82 @@
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"
import { useTranslation } from 'react-i18next'
interface ChatSecondaryButtonsProps {}
export const ChatSecondaryButtons: FC<ChatSecondaryButtonsProps> = ({}) => {
const { t } = useTranslation()
const { selectedChat } = useContext(ChatbotUIContext)
const { handleNewChat } = useChatHandler()
return (
<>
{selectedChat && (
<>
<WithTooltip
delayDuration={200}
display={
<div>
<div className="text-xl font-bold">{t("chatInfo.title")}</div>
<div className="mx-auto mt-2 max-w-xs space-y-2 sm:max-w-sm md:max-w-md lg:max-w-lg">
<div>{t("chatInfo.model")}: {selectedChat.model}</div>
<div>{t("chatInfo.prompt")}: {selectedChat.prompt}</div>
<div>{t("chatInfo.temperature")}: {selectedChat.temperature}</div>
<div>{t("chatInfo.contextLength")}: {selectedChat.context_length}</div>
<div>
{t("chatInfo.profileContext")}:{" "}
{selectedChat.include_profile_context
? t("chatInfo.enabled")
: t("chatInfo.disabled")}
</div>
<div>
{" "}
{t("chatInfo.workspaceInstructions")}:{" "}
{selectedChat.include_workspace_instructions
? t("chatInfo.enabled")
: t("chatInfo.disabled")}
</div>
<div>
{t("chatInfo.embeddingsProvider")}: {selectedChat.embeddings_provider}
</div>
</div>
</div>
}
trigger={
<div className="mt-1">
<IconInfoCircle
className="cursor-default hover:opacity-50"
size={24}
/>
</div>
}
/>
<WithTooltip
delayDuration={200}
display={<div>{t("chatInfo.startNewChat")}</div>}
trigger={
<div className="mt-1">
<IconMessagePlus
className="cursor-pointer hover:opacity-50"
size={24}
onClick={handleNewChat}
/>
</div>
}
/>
</>
)}
</>
)
}

View File

@ -0,0 +1,94 @@
import { ChatbotUIContext } from "@/context/context"
import { CHAT_SETTING_LIMITS } from "@/lib/chat-setting-limits"
import useHotkey from "@/lib/hooks/use-hotkey"
import { LLMID, ModelProvider } from "@/types"
import { IconAdjustmentsHorizontal } from "@tabler/icons-react"
import { FC, useContext, useEffect, useRef } from "react"
import { Button } from "../ui/button"
import { ChatSettingsForm } from "../ui/chat-settings-form"
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"
interface ChatSettingsProps {}
export const ChatSettings: FC<ChatSettingsProps> = ({}) => {
useHotkey("i", () => handleClick())
const {
chatSettings,
setChatSettings,
models,
availableHostedModels,
availableLocalModels,
availableOpenRouterModels
} = useContext(ChatbotUIContext)
const buttonRef = useRef<HTMLButtonElement>(null)
const handleClick = () => {
if (buttonRef.current) {
buttonRef.current.click()
}
}
useEffect(() => {
if (!chatSettings) return
setChatSettings({
...chatSettings,
temperature: Math.min(
chatSettings.temperature,
CHAT_SETTING_LIMITS[chatSettings.model]?.MAX_TEMPERATURE || 1
),
contextLength: Math.min(
chatSettings.contextLength,
CHAT_SETTING_LIMITS[chatSettings.model]?.MAX_CONTEXT_LENGTH || 4096
)
})
}, [chatSettings?.model])
if (!chatSettings) return null
const allModels = [
...models.map(model => ({
modelId: model.model_id as LLMID,
modelName: model.name,
provider: "custom" as ModelProvider,
hostedId: model.id,
platformLink: "",
imageInput: false
})),
...availableHostedModels,
...availableLocalModels,
...availableOpenRouterModels
]
const fullModel = allModels.find(llm => llm.modelId === chatSettings.model)
return (
<Popover>
<PopoverTrigger>
<Button
ref={buttonRef}
className="flex items-center space-x-2"
variant="ghost"
>
<div className="max-w-[120px] truncate text-lg sm:max-w-[300px] lg:max-w-[500px]">
{fullModel?.modelName || chatSettings.model}
</div>
<IconAdjustmentsHorizontal size={28} />
</Button>
</PopoverTrigger>
<PopoverContent
className="bg-background border-input relative flex max-h-[calc(100vh-60px)] w-[300px] flex-col space-y-4 overflow-auto rounded-lg border-2 p-6 sm:w-[350px] md:w-[400px] lg:w-[500px] dark:border-none"
align="end"
>
<ChatSettingsForm
chatSettings={chatSettings}
onChangeChatSettings={setChatSettings}
/>
</PopoverContent>
</Popover>
)
}

View File

@ -0,0 +1,234 @@
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"
import { useTranslation } from "react-i18next" // 引入 useTranslation 进行国际化处理
interface ChatUIProps {}
export const ChatUI: FC<ChatUIProps> = ({}) => {
useHotkey("o", () => handleNewChat())
const { t } = useTranslation() // 使用 t 函数进行国际化
const params = useParams()
const {
setChatMessages,
selectedChat,
setSelectedChat,
setChatSettings,
setChatImages,
assistants,
setSelectedAssistant,
setChatFileItems,
setChatFiles,
setShowFilesDisplay,
setUseRetrieval,
setSelectedTools
} = useContext(ChatbotUIContext)
const { handleNewChat, handleFocusChatInput } = useChatHandler()
const {
messagesStartRef,
messagesEndRef,
handleScroll,
scrollToBottom,
setIsAtBottom,
isAtTop,
isAtBottom,
isOverflowing,
scrollToTop
} = useScroll()
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchData = async () => {
await fetchMessages()
await fetchChat()
scrollToBottom()
setIsAtBottom(true)
}
if (params.chatid) {
fetchData().then(() => {
handleFocusChatInput()
setLoading(false)
})
} else {
setLoading(false)
}
}, [])
const fetchMessages = async () => {
const fetchedMessages = await getMessagesByChatId(params.chatid as string)
const imagePromises: Promise<MessageImage>[] = fetchedMessages.flatMap(
message =>
message.image_paths
? message.image_paths.map(async imagePath => {
const url = await getMessageImageFromStorage(imagePath)
if (url) {
const response = await fetch(url)
const blob = await response.blob()
const base64 = await convertBlobToBase64(blob)
return {
messageId: message.id,
path: imagePath,
base64,
url,
file: null
}
}
return {
messageId: message.id,
path: imagePath,
base64: "",
url,
file: null
}
})
: []
)
const images: MessageImage[] = await Promise.all(imagePromises.flat())
setChatImages(images)
const messageFileItemPromises = fetchedMessages.map(
async message => await getMessageFileItemsByMessageId(message.id)
)
const messageFileItems = await Promise.all(messageFileItemPromises)
const uniqueFileItems = messageFileItems.flatMap(item => item.file_items)
setChatFileItems(uniqueFileItems)
const chatFiles = await getChatFilesByChatId(params.chatid as string)
setChatFiles(
chatFiles.files.map(file => ({
id: file.id,
name: file.name,
type: file.type,
file: null
}))
)
setUseRetrieval(true)
setShowFilesDisplay(true)
const fetchedChatMessages = fetchedMessages.map(message => {
return {
message,
fileItems: messageFileItems
.filter(messageFileItem => messageFileItem.id === message.id)
.flatMap(messageFileItem =>
messageFileItem.file_items.map(fileItem => fileItem.id)
)
}
})
setChatMessages(fetchedChatMessages)
}
const fetchChat = async () => {
const chat = await getChatById(params.chatid as string)
if (!chat) return
if (chat.assistant_id) {
const assistant = assistants.find(
assistant => assistant.id === chat.assistant_id
)
if (assistant) {
setSelectedAssistant(assistant)
const assistantTools = (
await getAssistantToolsByAssistantId(assistant.id)
).tools
setSelectedTools(assistantTools)
}
}
setSelectedChat(chat)
setChatSettings({
model: chat.model as LLMID,
prompt: chat.prompt,
temperature: chat.temperature,
contextLength: chat.context_length,
includeProfileContext: chat.include_profile_context,
includeWorkspaceInstructions: chat.include_workspace_instructions,
embeddingsProvider: chat.embeddings_provider as "openai" | "local"
})
}
if (loading) {
return <Loading />
}
return (
<div className="relative flex h-full flex-col items-center">
<div className="absolute left-4 top-2.5 flex justify-center">
<ChatScrollButtons
isAtTop={isAtTop}
isAtBottom={isAtBottom}
isOverflowing={isOverflowing}
scrollToTop={scrollToTop}
scrollToBottom={scrollToBottom}
/>
</div>
<div className="absolute right-4 top-1 flex h-[40px] items-center space-x-2">
<ChatSecondaryButtons />
</div>
<div className="bg-secondary flex max-h-[50px] min-h-[50px] w-full items-center justify-center border-b-2 font-bold">
<div className="max-w-[200px] truncate sm:max-w-[400px] md:max-w-[500px] lg:max-w-[600px] xl:max-w-[700px]">
{selectedChat?.name || t("chat.defaultChatTitle")}
</div>
</div>
<div
className="flex size-full flex-col overflow-auto border-b"
onScroll={handleScroll}
>
<div ref={messagesStartRef} />
<ChatMessages />
<div ref={messagesEndRef} />
</div>
<div className="relative w-full min-w-[300px] items-end px-2 pb-3 pt-0 sm:w-[600px] sm:pb-8 sm:pt-5 md:w-[700px] lg:w-[700px] xl:w-[800px]">
<ChatInput />
</div>
<div className="absolute bottom-2 right-2 hidden md:block lg:bottom-4 lg:right-4">
<ChatHelp />
</div>
</div>
)
}

View File

@ -0,0 +1,158 @@
import { ChatbotUIContext } from "@/context/context"
import { Tables } from "@/supabase/types"
import { IconBooks } from "@tabler/icons-react"
import { FC, useContext, useEffect, useRef } from "react"
import { FileIcon } from "../ui/file-icon"
interface FilePickerProps {
isOpen: boolean
searchQuery: string
onOpenChange: (isOpen: boolean) => void
selectedFileIds: string[]
selectedCollectionIds: string[]
onSelectFile: (file: Tables<"files">) => void
onSelectCollection: (collection: Tables<"collections">) => void
isFocused: boolean
}
export const FilePicker: FC<FilePickerProps> = ({
isOpen,
searchQuery,
onOpenChange,
selectedFileIds,
selectedCollectionIds,
onSelectFile,
onSelectCollection,
isFocused
}) => {
const { files, collections, setIsFilePickerOpen } =
useContext(ChatbotUIContext)
const itemsRef = useRef<(HTMLDivElement | null)[]>([])
useEffect(() => {
if (isFocused && itemsRef.current[0]) {
itemsRef.current[0].focus()
}
}, [isFocused])
const filteredFiles = files.filter(
file =>
file.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
!selectedFileIds.includes(file.id)
)
const filteredCollections = collections.filter(
collection =>
collection.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
!selectedCollectionIds.includes(collection.id)
)
const handleOpenChange = (isOpen: boolean) => {
onOpenChange(isOpen)
}
const handleSelectFile = (file: Tables<"files">) => {
onSelectFile(file)
handleOpenChange(false)
}
const handleSelectCollection = (collection: Tables<"collections">) => {
onSelectCollection(collection)
handleOpenChange(false)
}
const getKeyDownHandler =
(index: number, type: "file" | "collection", item: any) =>
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Escape") {
e.preventDefault()
setIsFilePickerOpen(false)
} else if (e.key === "Backspace") {
e.preventDefault()
} else if (e.key === "Enter") {
e.preventDefault()
if (type === "file") {
handleSelectFile(item)
} else {
handleSelectCollection(item)
}
} else if (
(e.key === "Tab" || e.key === "ArrowDown") &&
!e.shiftKey &&
index === filteredFiles.length + filteredCollections.length - 1
) {
e.preventDefault()
itemsRef.current[0]?.focus()
} else if (e.key === "ArrowUp" && !e.shiftKey && index === 0) {
// go to last element if arrow up is pressed on first element
e.preventDefault()
itemsRef.current[itemsRef.current.length - 1]?.focus()
} else if (e.key === "ArrowUp") {
e.preventDefault()
const prevIndex =
index - 1 >= 0 ? index - 1 : itemsRef.current.length - 1
itemsRef.current[prevIndex]?.focus()
} else if (e.key === "ArrowDown") {
e.preventDefault()
const nextIndex = index + 1 < itemsRef.current.length ? index + 1 : 0
itemsRef.current[nextIndex]?.focus()
}
}
return (
<>
{isOpen && (
<div className="bg-background flex flex-col space-y-1 rounded-xl border-2 p-2 text-sm">
{filteredFiles.length === 0 && filteredCollections.length === 0 ? (
<div className="text-md flex h-14 cursor-pointer items-center justify-center italic hover:opacity-50">
No matching files.
</div>
) : (
<>
{[...filteredFiles, ...filteredCollections].map((item, index) => (
<div
key={item.id}
ref={ref => {
itemsRef.current[index] = ref
}}
tabIndex={0}
className="hover:bg-accent focus:bg-accent flex cursor-pointer items-center rounded p-2 focus:outline-none"
onClick={() => {
if ("type" in item) {
handleSelectFile(item as Tables<"files">)
} else {
handleSelectCollection(item)
}
}}
onKeyDown={e =>
getKeyDownHandler(
index,
"type" in item ? "file" : "collection",
item
)(e)
}
>
{"type" in item ? (
<FileIcon type={(item as Tables<"files">).type} size={32} />
) : (
<IconBooks size={32} />
)}
<div className="ml-3 flex flex-col">
<div className="font-bold">{item.name}</div>
<div className="truncate text-sm opacity-80">
{item.description || "No description."}
</div>
</div>
</div>
))}
</>
)}
</div>
)}
</>
)
}

View File

@ -0,0 +1,215 @@
import { ChatbotUIContext } from "@/context/context"
import { Tables } from "@/supabase/types"
import { FC, useContext, useEffect, useRef, useState } from "react"
import { Button } from "../ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog"
import { Label } from "../ui/label"
import { TextareaAutosize } from "../ui/textarea-autosize"
import { usePromptAndCommand } from "./chat-hooks/use-prompt-and-command"
interface PromptPickerProps {}
export const PromptPicker: FC<PromptPickerProps> = ({}) => {
const {
prompts,
isPromptPickerOpen,
setIsPromptPickerOpen,
focusPrompt,
slashCommand
} = useContext(ChatbotUIContext)
const { handleSelectPrompt } = usePromptAndCommand()
const itemsRef = useRef<(HTMLDivElement | null)[]>([])
const [promptVariables, setPromptVariables] = useState<
{
promptId: string
name: string
value: string
}[]
>([])
const [showPromptVariables, setShowPromptVariables] = useState(false)
useEffect(() => {
if (focusPrompt && itemsRef.current[0]) {
itemsRef.current[0].focus()
}
}, [focusPrompt])
const [isTyping, setIsTyping] = useState(false)
const filteredPrompts = prompts.filter(prompt =>
prompt.name.toLowerCase().includes(slashCommand.toLowerCase())
)
const handleOpenChange = (isOpen: boolean) => {
setIsPromptPickerOpen(isOpen)
}
const callSelectPrompt = (prompt: Tables<"prompts">) => {
const regex = /\{\{.*?\}\}/g
const matches = prompt.content.match(regex)
if (matches) {
const newPromptVariables = matches.map(match => ({
promptId: prompt.id,
name: match.replace(/\{\{|\}\}/g, ""),
value: ""
}))
setPromptVariables(newPromptVariables)
setShowPromptVariables(true)
} else {
handleSelectPrompt(prompt)
handleOpenChange(false)
}
}
const getKeyDownHandler =
(index: number) => (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Backspace") {
e.preventDefault()
handleOpenChange(false)
} else if (e.key === "Enter") {
e.preventDefault()
callSelectPrompt(filteredPrompts[index])
} else if (
(e.key === "Tab" || e.key === "ArrowDown") &&
!e.shiftKey &&
index === filteredPrompts.length - 1
) {
e.preventDefault()
itemsRef.current[0]?.focus()
} else if (e.key === "ArrowUp" && !e.shiftKey && index === 0) {
// go to last element if arrow up is pressed on first element
e.preventDefault()
itemsRef.current[itemsRef.current.length - 1]?.focus()
} else if (e.key === "ArrowUp") {
e.preventDefault()
const prevIndex =
index - 1 >= 0 ? index - 1 : itemsRef.current.length - 1
itemsRef.current[prevIndex]?.focus()
} else if (e.key === "ArrowDown") {
e.preventDefault()
const nextIndex = index + 1 < itemsRef.current.length ? index + 1 : 0
itemsRef.current[nextIndex]?.focus()
}
}
const handleSubmitPromptVariables = () => {
const newPromptContent = promptVariables.reduce(
(prevContent, variable) =>
prevContent.replace(
new RegExp(`\\{\\{${variable.name}\\}\\}`, "g"),
variable.value
),
prompts.find(prompt => prompt.id === promptVariables[0].promptId)
?.content || ""
)
const newPrompt: any = {
...prompts.find(prompt => prompt.id === promptVariables[0].promptId),
content: newPromptContent
}
handleSelectPrompt(newPrompt)
handleOpenChange(false)
setShowPromptVariables(false)
setPromptVariables([])
}
const handleCancelPromptVariables = () => {
setShowPromptVariables(false)
setPromptVariables([])
}
const handleKeydownPromptVariables = (
e: React.KeyboardEvent<HTMLDivElement>
) => {
if (!isTyping && e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSubmitPromptVariables()
}
}
return (
<>
{isPromptPickerOpen && (
<div className="bg-background flex flex-col space-y-1 rounded-xl border-2 p-2 text-sm">
{showPromptVariables ? (
<Dialog
open={showPromptVariables}
onOpenChange={setShowPromptVariables}
>
<DialogContent onKeyDown={handleKeydownPromptVariables}>
<DialogHeader>
<DialogTitle>Enter Prompt Variables</DialogTitle>
</DialogHeader>
<div className="mt-2 space-y-6">
{promptVariables.map((variable, index) => (
<div key={index} className="flex flex-col space-y-2">
<Label>{variable.name}</Label>
<TextareaAutosize
placeholder={`Enter a value for ${variable.name}...`}
value={variable.value}
onValueChange={value => {
const newPromptVariables = [...promptVariables]
newPromptVariables[index].value = value
setPromptVariables(newPromptVariables)
}}
minRows={3}
maxRows={5}
onCompositionStart={() => setIsTyping(true)}
onCompositionEnd={() => setIsTyping(false)}
/>
</div>
))}
</div>
<div className="mt-2 flex justify-end space-x-2">
<Button
variant="ghost"
size="sm"
onClick={handleCancelPromptVariables}
>
Cancel
</Button>
<Button size="sm" onClick={handleSubmitPromptVariables}>
Submit
</Button>
</div>
</DialogContent>
</Dialog>
) : filteredPrompts.length === 0 ? (
<div className="text-md flex h-14 cursor-pointer items-center justify-center italic hover:opacity-50">
No matching prompts.
</div>
) : (
filteredPrompts.map((prompt, index) => (
<div
key={prompt.id}
ref={ref => {
itemsRef.current[index] = ref
}}
tabIndex={0}
className="hover:bg-accent focus:bg-accent flex cursor-pointer flex-col rounded p-2 focus:outline-none"
onClick={() => callSelectPrompt(prompt)}
onKeyDown={getKeyDownHandler(index)}
>
<div className="font-bold">{prompt.name}</div>
<div className="truncate text-sm opacity-80">
{prompt.content}
</div>
</div>
))
)}
</div>
)}
</>
)
}

View File

@ -0,0 +1,71 @@
import { LLM_LIST } from "@/lib/models/llm/llm-list"
import { Tables } from "@/supabase/types"
import { IconCircleCheckFilled, IconRobotFace } from "@tabler/icons-react"
import Image from "next/image"
import { FC } from "react"
import { ModelIcon } from "../models/model-icon"
import { DropdownMenuItem } from "../ui/dropdown-menu"
interface QuickSettingOptionProps {
contentType: "presets" | "assistants"
isSelected: boolean
item: Tables<"presets"> | Tables<"assistants">
onSelect: () => void
image: string
}
export const QuickSettingOption: FC<QuickSettingOptionProps> = ({
contentType,
isSelected,
item,
onSelect,
image
}) => {
const modelDetails = LLM_LIST.find(model => model.modelId === item.model)
return (
<DropdownMenuItem
tabIndex={0}
className="cursor-pointer items-center"
onSelect={onSelect}
>
<div className="w-[32px]">
{contentType === "presets" ? (
<ModelIcon
provider={modelDetails?.provider || "custom"}
width={32}
height={32}
/>
) : image ? (
<Image
style={{ width: "32px", height: "32px" }}
className="rounded"
src={image}
alt="Assistant"
width={32}
height={32}
/>
) : (
<IconRobotFace
className="bg-primary text-secondary border-primary rounded border-DEFAULT p-1"
size={32}
/>
)}
</div>
<div className="ml-4 flex grow flex-col space-y-1">
<div className="text-md font-bold">{item.name}</div>
{item.description && (
<div className="text-sm font-light">{item.description}</div>
)}
</div>
<div className="min-w-[40px]">
{isSelected ? (
<IconCircleCheckFilled className="ml-4" size={20} />
) : null}
</div>
</DropdownMenuItem>
)
}

View File

@ -0,0 +1,308 @@
import { ChatbotUIContext } from "@/context/context"
import { getAssistantCollectionsByAssistantId } from "@/db/assistant-collections"
import { getAssistantFilesByAssistantId } from "@/db/assistant-files"
import { getAssistantToolsByAssistantId } from "@/db/assistant-tools"
import { getCollectionFilesByCollectionId } from "@/db/collection-files"
import useHotkey from "@/lib/hooks/use-hotkey"
import { LLM_LIST } from "@/lib/models/llm/llm-list"
import { Tables } from "@/supabase/types"
import { LLMID } from "@/types"
import { IconChevronDown, IconRobotFace } from "@tabler/icons-react"
import Image from "next/image"
import { FC, useContext, useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { ModelIcon } from "../models/model-icon"
import { Button } from "../ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger
} from "../ui/dropdown-menu"
import { Input } from "../ui/input"
import { QuickSettingOption } from "./quick-setting-option"
import { set } from "date-fns"
interface QuickSettingsProps {}
export const QuickSettings: FC<QuickSettingsProps> = ({}) => {
const { t } = useTranslation()
useHotkey("p", () => setIsOpen(prevState => !prevState))
const {
presets,
assistants,
selectedAssistant,
selectedPreset,
chatSettings,
setSelectedPreset,
setSelectedAssistant,
setChatSettings,
assistantImages,
setChatFiles,
setSelectedTools,
setShowFilesDisplay,
selectedWorkspace
} = useContext(ChatbotUIContext)
const inputRef = useRef<HTMLInputElement>(null)
const [isOpen, setIsOpen] = useState(false)
const [search, setSearch] = useState("")
const [loading, setLoading] = useState(false)
useEffect(() => {
if (isOpen) {
setTimeout(() => {
inputRef.current?.focus()
}, 100) // FIX: hacky
}
}, [isOpen])
const handleSelectQuickSetting = async (
item: Tables<"presets"> | Tables<"assistants"> | null,
contentType: "presets" | "assistants" | "remove"
) => {
console.log({ item, contentType })
if (contentType === "assistants" && item) {
setSelectedAssistant(item as Tables<"assistants">)
setLoading(true)
let allFiles = []
const assistantFiles = (await getAssistantFilesByAssistantId(item.id))
.files
allFiles = [...assistantFiles]
const assistantCollections = (
await getAssistantCollectionsByAssistantId(item.id)
).collections
for (const collection of assistantCollections) {
const collectionFiles = (
await getCollectionFilesByCollectionId(collection.id)
).files
allFiles = [...allFiles, ...collectionFiles]
}
const assistantTools = (await getAssistantToolsByAssistantId(item.id))
.tools
setSelectedTools(assistantTools)
setChatFiles(
allFiles.map(file => ({
id: file.id,
name: file.name,
type: file.type,
file: null
}))
)
if (allFiles.length > 0) setShowFilesDisplay(true)
setLoading(false)
setSelectedPreset(null)
} else if (contentType === "presets" && item) {
setSelectedPreset(item as Tables<"presets">)
setSelectedAssistant(null)
setChatFiles([])
setSelectedTools([])
} else {
setSelectedPreset(null)
setSelectedAssistant(null)
setChatFiles([])
setSelectedTools([])
if (selectedWorkspace) {
setChatSettings({
model: selectedWorkspace.default_model as LLMID,
prompt: selectedWorkspace.default_prompt,
temperature: selectedWorkspace.default_temperature,
contextLength: selectedWorkspace.default_context_length,
includeProfileContext: selectedWorkspace.include_profile_context,
includeWorkspaceInstructions:
selectedWorkspace.include_workspace_instructions,
embeddingsProvider: selectedWorkspace.embeddings_provider as
| "openai"
| "local"
})
}
return
}
setChatSettings({
model: item.model as LLMID,
prompt: item.prompt,
temperature: item.temperature,
contextLength: item.context_length,
includeProfileContext: item.include_profile_context,
includeWorkspaceInstructions: item.include_workspace_instructions,
embeddingsProvider: item.embeddings_provider as "openai" | "local"
})
}
const checkIfModified = () => {
if (!chatSettings) return false
if (selectedPreset) {
return (
selectedPreset.include_profile_context !==
chatSettings?.includeProfileContext ||
selectedPreset.include_workspace_instructions !==
chatSettings.includeWorkspaceInstructions ||
selectedPreset.context_length !== chatSettings.contextLength ||
selectedPreset.model !== chatSettings.model ||
selectedPreset.prompt !== chatSettings.prompt ||
selectedPreset.temperature !== chatSettings.temperature
)
} else if (selectedAssistant) {
return (
selectedAssistant.include_profile_context !==
chatSettings.includeProfileContext ||
selectedAssistant.include_workspace_instructions !==
chatSettings.includeWorkspaceInstructions ||
selectedAssistant.context_length !== chatSettings.contextLength ||
selectedAssistant.model !== chatSettings.model ||
selectedAssistant.prompt !== chatSettings.prompt ||
selectedAssistant.temperature !== chatSettings.temperature
)
}
return false
}
const isModified = checkIfModified()
const items = [
...presets.map(preset => ({ ...preset, contentType: "presets" })),
...assistants.map(assistant => ({
...assistant,
contentType: "assistants"
}))
]
const selectedAssistantImage = selectedPreset
? ""
: assistantImages.find(
image => image.path === selectedAssistant?.image_path
)?.base64 || ""
const modelDetails = LLM_LIST.find(
model => model.modelId === selectedPreset?.model
)
return (
<DropdownMenu
open={isOpen}
onOpenChange={isOpen => {
setIsOpen(isOpen)
setSearch("")
}}
>
<DropdownMenuTrigger asChild className="max-w-[400px]" disabled={loading}>
<Button variant="ghost" className="flex space-x-3 text-lg">
{selectedPreset && (
<ModelIcon
provider={modelDetails?.provider || "custom"}
width={32}
height={32}
/>
)}
{selectedAssistant &&
(selectedAssistantImage ? (
<Image
className="rounded"
src={selectedAssistantImage}
alt="Assistant"
width={28}
height={28}
/>
) : (
<IconRobotFace
className="bg-primary text-secondary border-primary rounded border-DEFAULT p-1"
size={28}
/>
))}
{loading ? (
<div className="animate-pulse">{t("chat.loadingAssistant")}</div>
) : (
<>
<div className="overflow-hidden text-ellipsis">
{isModified &&
(selectedPreset || selectedAssistant) &&
"Modified "}
{selectedPreset?.name ||
selectedAssistant?.name ||
t("chat.quickSettingsLabel")}
</div>
<IconChevronDown className="ml-1" />
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-[300px] max-w-[500px] space-y-4"
align="start"
>
{presets.length === 0 && assistants.length === 0 ? (
<div className="p-8 text-center">{t("chat.noItemsFound")}</div>
) : (
<>
<Input
ref={inputRef}
className="w-full"
placeholder="Search..."
value={search}
onChange={e => setSearch(e.target.value)}
onKeyDown={e => e.stopPropagation()}
/>
{!!(selectedPreset || selectedAssistant) && (
<QuickSettingOption
contentType={selectedPreset ? "presets" : "assistants"}
isSelected={true}
item={
selectedPreset ||
(selectedAssistant as
| Tables<"presets">
| Tables<"assistants">)
}
onSelect={() => {
handleSelectQuickSetting(null, "remove")
}}
image={selectedPreset ? "" : selectedAssistantImage}
/>
)}
{items
.filter(
item =>
item.name.toLowerCase().includes(search.toLowerCase()) &&
item.id !== selectedPreset?.id &&
item.id !== selectedAssistant?.id
)
.map(({ contentType, ...item }) => (
<QuickSettingOption
key={item.id}
contentType={contentType as "presets" | "assistants"}
isSelected={false}
item={item}
onSelect={() =>
handleSelectQuickSetting(
item,
contentType as "presets" | "assistants"
)
}
image={
contentType === "assistants"
? assistantImages.find(
image =>
image.path ===
(item as Tables<"assistants">).image_path
)?.base64 || ""
: ""
}
/>
))}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -0,0 +1,110 @@
import { ChatbotUIContext } from "@/context/context"
import { Tables } from "@/supabase/types"
import { IconBolt } from "@tabler/icons-react"
import { FC, useContext, useEffect, useRef } from "react"
import { usePromptAndCommand } from "./chat-hooks/use-prompt-and-command"
interface ToolPickerProps {}
export const ToolPicker: FC<ToolPickerProps> = ({}) => {
const {
tools,
focusTool,
toolCommand,
isToolPickerOpen,
setIsToolPickerOpen
} = useContext(ChatbotUIContext)
const { handleSelectTool } = usePromptAndCommand()
const itemsRef = useRef<(HTMLDivElement | null)[]>([])
useEffect(() => {
if (focusTool && itemsRef.current[0]) {
itemsRef.current[0].focus()
}
}, [focusTool])
const filteredTools = tools.filter(tool =>
tool.name.toLowerCase().includes(toolCommand.toLowerCase())
)
const handleOpenChange = (isOpen: boolean) => {
setIsToolPickerOpen(isOpen)
}
const callSelectTool = (tool: Tables<"tools">) => {
handleSelectTool(tool)
handleOpenChange(false)
}
const getKeyDownHandler =
(index: number) => (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Backspace") {
e.preventDefault()
handleOpenChange(false)
} else if (e.key === "Enter") {
e.preventDefault()
callSelectTool(filteredTools[index])
} else if (
(e.key === "Tab" || e.key === "ArrowDown") &&
!e.shiftKey &&
index === filteredTools.length - 1
) {
e.preventDefault()
itemsRef.current[0]?.focus()
} else if (e.key === "ArrowUp" && !e.shiftKey && index === 0) {
// go to last element if arrow up is pressed on first element
e.preventDefault()
itemsRef.current[itemsRef.current.length - 1]?.focus()
} else if (e.key === "ArrowUp") {
e.preventDefault()
const prevIndex =
index - 1 >= 0 ? index - 1 : itemsRef.current.length - 1
itemsRef.current[prevIndex]?.focus()
} else if (e.key === "ArrowDown") {
e.preventDefault()
const nextIndex = index + 1 < itemsRef.current.length ? index + 1 : 0
itemsRef.current[nextIndex]?.focus()
}
}
return (
<>
{isToolPickerOpen && (
<div className="bg-background flex flex-col space-y-1 rounded-xl border-2 p-2 text-sm">
{filteredTools.length === 0 ? (
<div className="text-md flex h-14 cursor-pointer items-center justify-center italic hover:opacity-50">
No matching tools.
</div>
) : (
<>
{filteredTools.map((item, index) => (
<div
key={item.id}
ref={ref => {
itemsRef.current[index] = ref
}}
tabIndex={0}
className="hover:bg-accent focus:bg-accent flex cursor-pointer items-center rounded p-2 focus:outline-none"
onClick={() => callSelectTool(item as Tables<"tools">)}
onKeyDown={getKeyDownHandler(index)}
>
<IconBolt size={32} />
<div className="ml-3 flex flex-col">
<div className="font-bold">{item.name}</div>
<div className="truncate text-sm opacity-80">
{item.description || "No description."}
</div>
</div>
</div>
))}
</>
)}
</div>
)}
</>
)
}

View File

@ -0,0 +1,44 @@
import { FC } from "react"
interface AnthropicSVGProps {
height?: number
width?: number
className?: string
}
export const AnthropicSVG: FC<AnthropicSVGProps> = ({
height = 40,
width = 40,
className
}) => {
return (
<svg
className={className}
width={width}
height={height}
viewBox="0 0 24 16"
overflow="visible"
>
<g
style={{
transform: "translateX(13px) rotateZ(0deg)",
transformOrigin: "4.775px 7.73501px"
}}
>
<path
fill="currentColor"
d=" M0,0 C0,0 6.1677093505859375,15.470022201538086 6.1677093505859375,15.470022201538086 C6.1677093505859375,15.470022201538086 9.550004005432129,15.470022201538086 9.550004005432129,15.470022201538086 C9.550004005432129,15.470022201538086 3.382294178009033,0 3.382294178009033,0 C3.382294178009033,0 0,0 0,0 C0,0 0,0 0,0z"
></path>
</g>
<g
style={{ transform: "none", transformOrigin: "7.935px 7.73501px" }}
opacity="1"
>
<path
fill="currentColor"
d=" M5.824605464935303,9.348296165466309 C5.824605464935303,9.348296165466309 7.93500280380249,3.911694288253784 7.93500280380249,3.911694288253784 C7.93500280380249,3.911694288253784 10.045400619506836,9.348296165466309 10.045400619506836,9.348296165466309 C10.045400619506836,9.348296165466309 5.824605464935303,9.348296165466309 5.824605464935303,9.348296165466309 C5.824605464935303,9.348296165466309 5.824605464935303,9.348296165466309 5.824605464935303,9.348296165466309z M6.166755199432373,0 C6.166755199432373,0 0,15.470022201538086 0,15.470022201538086 C0,15.470022201538086 3.4480772018432617,15.470022201538086 3.4480772018432617,15.470022201538086 C3.4480772018432617,15.470022201538086 4.709278583526611,12.22130012512207 4.709278583526611,12.22130012512207 C4.709278583526611,12.22130012512207 11.16093635559082,12.22130012512207 11.16093635559082,12.22130012512207 C11.16093635559082,12.22130012512207 12.421928405761719,15.470022201538086 12.421928405761719,15.470022201538086 C12.421928405761719,15.470022201538086 15.87000560760498,15.470022201538086 15.87000560760498,15.470022201538086 C15.87000560760498,15.470022201538086 9.703250885009766,0 9.703250885009766,0 C9.703250885009766,0 6.166755199432373,0 6.166755199432373,0 C6.166755199432373,0 6.166755199432373,0 6.166755199432373,0z"
></path>
</g>
</svg>
)
}

View File

@ -0,0 +1,37 @@
import { FC } from "react"
interface ChatbotUISVGProps {
theme: "dark" | "light"
scale?: number
}
export const ChatbotUISVG: FC<ChatbotUISVGProps> = ({ theme, scale = 1 }) => {
return (
<svg
width={189 * scale}
height={194 * scale}
viewBox="0 0 189 194"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="12.5"
y="12.5"
width="164"
height="127"
rx="37.5"
fill={`${theme === "dark" ? "#000" : "#fff"}`}
stroke={`${theme === "dark" ? "#fff" : "#000"}`}
strokeWidth="25"
/>
<path
d="M72.7643 143.457C77.2953 143.443 79.508 148.98 76.2146 152.092L42.7738 183.69C39.5361 186.749 34.2157 184.366 34.3419 179.914L35.2341 148.422C35.3106 145.723 37.5158 143.572 40.2158 143.564L72.7643 143.457Z"
fill={`${theme === "dark" ? "#fff" : "#000"}`}
/>
<path
d="M59.6722 51.6H75.5122V84C75.5122 86.016 76.0162 87.672 77.0242 88.968C78.0802 90.216 79.6882 90.84 81.8482 90.84C84.0082 90.84 85.5922 90.216 86.6002 88.968C87.6562 87.672 88.1842 86.016 88.1842 84V51.6H104.024V85.44C104.024 89.04 103.424 92.088 102.224 94.584C101.072 97.032 99.4642 99.024 97.4002 100.56C95.3362 102.048 92.9602 103.128 90.2722 103.8C87.6322 104.52 84.8242 104.88 81.8482 104.88C78.8722 104.88 76.0402 104.52 73.3522 103.8C70.7122 103.128 68.3602 102.048 66.2962 100.56C64.2322 99.024 62.6002 97.032 61.4002 94.584C60.2482 92.088 59.6722 89.04 59.6722 85.44V51.6ZM113.751 51.6H129.951V102H113.751V51.6Z"
fill={`${theme === "dark" ? "#fff" : "#000"}`}
/>
</svg>
)
}

View File

@ -0,0 +1,42 @@
import { FC } from "react"
interface GoogleSVGProps {
height?: number
width?: number
className?: string
}
export const GoogleSVG: FC<GoogleSVGProps> = ({
height = 40,
width = 40,
className
}) => {
return (
<svg
className={className}
width={width}
height={height}
xmlns="http://www.w3.org/2000/svg"
x="0px"
y="0px"
viewBox="0 0 48 48"
>
<path
fill="#FFC107"
d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"
></path>
<path
fill="#FF3D00"
d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"
></path>
<path
fill="#4CAF50"
d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"
></path>
<path
fill="#1976D2"
d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"
></path>
</svg>
)
}

View File

@ -0,0 +1,31 @@
import { FC } from "react"
interface OpenAISVGProps {
height?: number
width?: number
className?: string
}
export const OpenAISVG: FC<OpenAISVGProps> = ({
height = 40,
width = 40,
className
}) => {
return (
<svg
className={className}
width={width}
height={height}
viewBox="0 0 41 41"
fill="none"
xmlns="http://www.w3.org/2000/svg"
strokeWidth="1.5"
role="img"
>
<path
d="M37.5324 16.8707C37.9808 15.5241 38.1363 14.0974 37.9886 12.6859C37.8409 11.2744 37.3934 9.91076 36.676 8.68622C35.6126 6.83404 33.9882 5.3676 32.0373 4.4985C30.0864 3.62941 27.9098 3.40259 25.8215 3.85078C24.8796 2.7893 23.7219 1.94125 22.4257 1.36341C21.1295 0.785575 19.7249 0.491269 18.3058 0.500197C16.1708 0.495044 14.0893 1.16803 12.3614 2.42214C10.6335 3.67624 9.34853 5.44666 8.6917 7.47815C7.30085 7.76286 5.98686 8.3414 4.8377 9.17505C3.68854 10.0087 2.73073 11.0782 2.02839 12.312C0.956464 14.1591 0.498905 16.2988 0.721698 18.4228C0.944492 20.5467 1.83612 22.5449 3.268 24.1293C2.81966 25.4759 2.66413 26.9026 2.81182 28.3141C2.95951 29.7256 3.40701 31.0892 4.12437 32.3138C5.18791 34.1659 6.8123 35.6322 8.76321 36.5013C10.7141 37.3704 12.8907 37.5973 14.9789 37.1492C15.9208 38.2107 17.0786 39.0587 18.3747 39.6366C19.6709 40.2144 21.0755 40.5087 22.4946 40.4998C24.6307 40.5054 26.7133 39.8321 28.4418 38.5772C30.1704 37.3223 31.4556 35.5506 32.1119 33.5179C33.5027 33.2332 34.8167 32.6547 35.9659 31.821C37.115 30.9874 38.0728 29.9178 38.7752 28.684C39.8458 26.8371 40.3023 24.6979 40.0789 22.5748C39.8556 20.4517 38.9639 18.4544 37.5324 16.8707ZM22.4978 37.8849C20.7443 37.8874 19.0459 37.2733 17.6994 36.1501C17.7601 36.117 17.8666 36.0586 17.936 36.0161L25.9004 31.4156C26.1003 31.3019 26.2663 31.137 26.3813 30.9378C26.4964 30.7386 26.5563 30.5124 26.5549 30.2825V19.0542L29.9213 20.998C29.9389 21.0068 29.9541 21.0198 29.9656 21.0359C29.977 21.052 29.9842 21.0707 29.9867 21.0902V30.3889C29.9842 32.375 29.1946 34.2791 27.7909 35.6841C26.3872 37.0892 24.4838 37.8806 22.4978 37.8849ZM6.39227 31.0064C5.51397 29.4888 5.19742 27.7107 5.49804 25.9832C5.55718 26.0187 5.66048 26.0818 5.73461 26.1244L13.699 30.7248C13.8975 30.8408 14.1233 30.902 14.3532 30.902C14.583 30.902 14.8088 30.8408 15.0073 30.7248L24.731 25.1103V28.9979C24.7321 29.0177 24.7283 29.0376 24.7199 29.0556C24.7115 29.0736 24.6988 29.0893 24.6829 29.1012L16.6317 33.7497C14.9096 34.7416 12.8643 35.0097 10.9447 34.4954C9.02506 33.9811 7.38785 32.7263 6.39227 31.0064ZM4.29707 13.6194C5.17156 12.0998 6.55279 10.9364 8.19885 10.3327C8.19885 10.4013 8.19491 10.5228 8.19491 10.6071V19.808C8.19351 20.0378 8.25334 20.2638 8.36823 20.4629C8.48312 20.6619 8.64893 20.8267 8.84863 20.9404L18.5723 26.5542L15.206 28.4979C15.1894 28.5089 15.1703 28.5155 15.1505 28.5173C15.1307 28.5191 15.1107 28.516 15.0924 28.5082L7.04046 23.8557C5.32135 22.8601 4.06716 21.2235 3.55289 19.3046C3.03862 17.3858 3.30624 15.3413 4.29707 13.6194ZM31.955 20.0556L22.2312 14.4411L25.5976 12.4981C25.6142 12.4872 25.6333 12.4805 25.6531 12.4787C25.6729 12.4769 25.6928 12.4801 25.7111 12.4879L33.7631 17.1364C34.9967 17.849 36.0017 18.8982 36.6606 20.1613C37.3194 21.4244 37.6047 22.849 37.4832 24.2684C37.3617 25.6878 36.8382 27.0432 35.9743 28.1759C35.1103 29.3086 33.9415 30.1717 32.6047 30.6641C32.6047 30.5947 32.6047 30.4733 32.6047 30.3889V21.188C32.6066 20.9586 32.5474 20.7328 32.4332 20.5338C32.319 20.3348 32.154 20.1698 31.955 20.0556ZM35.3055 15.0128C35.2464 14.9765 35.1431 14.9142 35.069 14.8717L27.1045 10.2712C26.906 10.1554 26.6803 10.0943 26.4504 10.0943C26.2206 10.0943 25.9948 10.1554 25.7963 10.2712L16.0726 15.8858V11.9982C16.0715 11.9783 16.0753 11.9585 16.0837 11.9405C16.0921 11.9225 16.1048 11.9068 16.1207 11.8949L24.1719 7.25025C25.4053 6.53903 26.8158 6.19376 28.2383 6.25482C29.6608 6.31589 31.0364 6.78077 32.2044 7.59508C33.3723 8.40939 34.2842 9.53945 34.8334 10.8531C35.3826 12.1667 35.5464 13.6095 35.3055 15.0128ZM14.2424 21.9419L10.8752 19.9981C10.8576 19.9893 10.8423 19.9763 10.8309 19.9602C10.8195 19.9441 10.8122 19.9254 10.8098 19.9058V10.6071C10.8107 9.18295 11.2173 7.78848 11.9819 6.58696C12.7466 5.38544 13.8377 4.42659 15.1275 3.82264C16.4173 3.21869 17.8524 2.99464 19.2649 3.1767C20.6775 3.35876 22.0089 3.93941 23.1034 4.85067C23.0427 4.88379 22.937 4.94215 22.8668 4.98473L14.9024 9.58517C14.7025 9.69878 14.5366 9.86356 14.4215 10.0626C14.3065 10.2616 14.2466 10.4877 14.2479 10.7175L14.2424 21.9419ZM16.071 17.9991L20.4018 15.4978L24.7325 17.9975V22.9985L20.4018 25.4983L16.071 22.9985V17.9991Z"
fill="currentColor"
></path>
</svg>
)
}

View File

@ -0,0 +1,117 @@
import { ChatbotUIContext } from "@/context/context"
import { IconCheck, IconCopy, IconEdit, IconRepeat } from "@tabler/icons-react"
import { FC, useContext, useEffect, useState } from "react"
import { WithTooltip } from "../ui/with-tooltip"
export const MESSAGE_ICON_SIZE = 18
interface MessageActionsProps {
isAssistant: boolean
isLast: boolean
isEditing: boolean
isHovering: boolean
onCopy: () => void
onEdit: () => void
onRegenerate: () => void
}
export const MessageActions: FC<MessageActionsProps> = ({
isAssistant,
isLast,
isEditing,
isHovering,
onCopy,
onEdit,
onRegenerate
}) => {
const { isGenerating } = useContext(ChatbotUIContext)
const [showCheckmark, setShowCheckmark] = useState(false)
const handleCopy = () => {
onCopy()
setShowCheckmark(true)
}
const handleForkChat = async () => {}
useEffect(() => {
if (showCheckmark) {
const timer = setTimeout(() => {
setShowCheckmark(false)
}, 2000)
return () => clearTimeout(timer)
}
}, [showCheckmark])
return (isLast && isGenerating) || isEditing ? null : (
<div className="text-muted-foreground flex items-center space-x-2">
{/* {((isAssistant && isHovering) || isLast) && (
<WithTooltip
delayDuration={1000}
side="bottom"
display={<div>Fork Chat</div>}
trigger={
<IconGitFork
className="cursor-pointer hover:opacity-50"
size={MESSAGE_ICON_SIZE}
onClick={handleForkChat}
/>
}
/>
)} */}
{!isAssistant && isHovering && (
<WithTooltip
delayDuration={1000}
side="bottom"
display={<div>Edit</div>}
trigger={
<IconEdit
className="cursor-pointer hover:opacity-50"
size={MESSAGE_ICON_SIZE}
onClick={onEdit}
/>
}
/>
)}
{(isHovering || isLast) && (
<WithTooltip
delayDuration={1000}
side="bottom"
display={<div>Copy</div>}
trigger={
showCheckmark ? (
<IconCheck size={MESSAGE_ICON_SIZE} />
) : (
<IconCopy
className="cursor-pointer hover:opacity-50"
size={MESSAGE_ICON_SIZE}
onClick={handleCopy}
/>
)
}
/>
)}
{isLast && (
<WithTooltip
delayDuration={1000}
side="bottom"
display={<div>Regenerate</div>}
trigger={
<IconRepeat
className="cursor-pointer hover:opacity-50"
size={MESSAGE_ICON_SIZE}
onClick={onRegenerate}
/>
}
/>
)}
{/* {1 > 0 && isAssistant && <MessageReplies />} */}
</div>
)
}

View File

@ -0,0 +1,135 @@
import { Button } from "@/components/ui/button"
import { useCopyToClipboard } from "@/lib/hooks/use-copy-to-clipboard"
import { IconCheck, IconCopy, IconDownload } from "@tabler/icons-react"
import { FC, memo } from "react"
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
import { oneDark } from "react-syntax-highlighter/dist/cjs/styles/prism"
interface MessageCodeBlockProps {
language: string
value: string
}
interface languageMap {
[key: string]: string | undefined
}
export const programmingLanguages: languageMap = {
javascript: ".js",
python: ".py",
java: ".java",
c: ".c",
cpp: ".cpp",
"c++": ".cpp",
"c#": ".cs",
ruby: ".rb",
php: ".php",
swift: ".swift",
"objective-c": ".m",
kotlin: ".kt",
typescript: ".ts",
go: ".go",
perl: ".pl",
rust: ".rs",
scala: ".scala",
haskell: ".hs",
lua: ".lua",
shell: ".sh",
sql: ".sql",
html: ".html",
css: ".css"
}
export const generateRandomString = (length: number, lowercase = false) => {
const chars = "ABCDEFGHJKLMNPQRSTUVWXY3456789" // excluding similar looking characters like Z, 2, I, 1, O, 0
let result = ""
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return lowercase ? result.toLowerCase() : result
}
export const MessageCodeBlock: FC<MessageCodeBlockProps> = memo(
({ language, value }) => {
const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 })
const downloadAsFile = () => {
if (typeof window === "undefined") {
return
}
const fileExtension = programmingLanguages[language] || ".file"
const suggestedFileName = `file-${generateRandomString(
3,
true
)}${fileExtension}`
const fileName = window.prompt("Enter file name" || "", suggestedFileName)
if (!fileName) {
return
}
const blob = new Blob([value], { type: "text/plain" })
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.download = fileName
link.href = url
link.style.display = "none"
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
const onCopy = () => {
if (isCopied) return
copyToClipboard(value)
}
return (
<div className="codeblock relative w-full bg-zinc-950 font-sans">
<div className="flex w-full items-center justify-between bg-zinc-700 px-4 text-white">
<span className="text-xs lowercase">{language}</span>
<div className="flex items-center space-x-1">
<Button
variant="ghost"
size="icon"
className="hover:bg-zinc-800 focus-visible:ring-1 focus-visible:ring-slate-700 focus-visible:ring-offset-0"
onClick={downloadAsFile}
>
<IconDownload size={16} />
</Button>
<Button
variant="ghost"
size="icon"
className="text-xs hover:bg-zinc-800 focus-visible:ring-1 focus-visible:ring-slate-700 focus-visible:ring-offset-0"
onClick={onCopy}
>
{isCopied ? <IconCheck size={16} /> : <IconCopy size={16} />}
</Button>
</div>
</div>
<SyntaxHighlighter
language={language}
style={oneDark}
// showLineNumbers
customStyle={{
margin: 0,
width: "100%",
background: "transparent"
}}
codeTagProps={{
style: {
fontSize: "14px",
fontFamily: "var(--font-mono)"
}
}}
>
{value}
</SyntaxHighlighter>
</div>
)
}
)
MessageCodeBlock.displayName = "MessageCodeBlock"

View File

@ -0,0 +1,9 @@
import { FC, memo } from "react"
import ReactMarkdown, { Options } from "react-markdown"
export const MessageMarkdownMemoized: FC<Options> = memo(
ReactMarkdown,
(prevProps, nextProps) =>
prevProps.children === nextProps.children &&
prevProps.className === nextProps.className
)

View File

@ -0,0 +1,65 @@
import React, { FC } from "react"
import remarkGfm from "remark-gfm"
import remarkMath from "remark-math"
import { MessageCodeBlock } from "./message-codeblock"
import { MessageMarkdownMemoized } from "./message-markdown-memoized"
interface MessageMarkdownProps {
content: string
}
export const MessageMarkdown: FC<MessageMarkdownProps> = ({ content }) => {
return (
<MessageMarkdownMemoized
className="prose dark:prose-invert prose-p:leading-relaxed prose-pre:p-0 min-w-full space-y-6 break-words"
remarkPlugins={[remarkGfm, remarkMath]}
components={{
p({ children }) {
return <p className="mb-2 last:mb-0">{children}</p>
},
img({ node, ...props }) {
return <img className="max-w-[67%]" {...props} />
},
code({ node, className, children, ...props }) {
const childArray = React.Children.toArray(children)
const firstChild = childArray[0] as React.ReactElement
const firstChildAsString = React.isValidElement(firstChild)
? (firstChild as React.ReactElement).props.children
: firstChild
if (firstChildAsString === "▍") {
return <span className="mt-1 animate-pulse cursor-default"></span>
}
if (typeof firstChildAsString === "string") {
childArray[0] = firstChildAsString.replace("`▍`", "▍")
}
const match = /language-(\w+)/.exec(className || "")
if (
typeof firstChildAsString === "string" &&
!firstChildAsString.includes("\n")
) {
return (
<code className={className} {...props}>
{childArray}
</code>
)
}
return (
<MessageCodeBlock
key={Math.random()}
language={(match && match[1]) || ""}
value={String(childArray).replace(/\n$/, "")}
{...props}
/>
)
}
}}
>
{content}
</MessageMarkdownMemoized>
)
}

View File

@ -0,0 +1,51 @@
import { IconMessage } from "@tabler/icons-react"
import { FC, useState } from "react"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger
} from "../ui/sheet"
import { WithTooltip } from "../ui/with-tooltip"
import { MESSAGE_ICON_SIZE } from "./message-actions"
interface MessageRepliesProps {}
export const MessageReplies: FC<MessageRepliesProps> = ({}) => {
const [isOpen, setIsOpen] = useState(false)
return (
<Sheet open={isOpen} onOpenChange={setIsOpen}>
<SheetTrigger asChild>
<WithTooltip
delayDuration={1000}
side="bottom"
display={<div>View Replies</div>}
trigger={
<div
className="relative cursor-pointer hover:opacity-50"
onClick={() => setIsOpen(true)}
>
<IconMessage size={MESSAGE_ICON_SIZE} />
<div className="notification-indicator absolute right-[-4px] top-[-4px] flex size-3 items-center justify-center rounded-full bg-red-600 text-[8px] text-white">
{1}
</div>
</div>
}
/>
</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Are you sure absolutely sure?</SheetTitle>
<SheetDescription>
This action cannot be undone. This will permanently delete your
account and remove your data from our servers.
</SheetDescription>
</SheetHeader>
</SheetContent>
</Sheet>
)
}

View File

@ -0,0 +1,445 @@
import { useChatHandler } from "@/components/chat/chat-hooks/use-chat-handler"
import { ChatbotUIContext } from "@/context/context"
import { LLM_LIST } from "@/lib/models/llm/llm-list"
import { cn } from "@/lib/utils"
import { Tables } from "@/supabase/types"
import { LLM, LLMID, MessageImage, ModelProvider } from "@/types"
import {
IconBolt,
IconCaretDownFilled,
IconCaretRightFilled,
IconCircleFilled,
IconFileText,
IconMoodSmile,
IconPencil
} from "@tabler/icons-react"
import Image from "next/image"
import { FC, useContext, useEffect, useRef, useState } from "react"
import { ModelIcon } from "../models/model-icon"
import { Button } from "../ui/button"
import { FileIcon } from "../ui/file-icon"
import { FilePreview } from "../ui/file-preview"
import { TextareaAutosize } from "../ui/textarea-autosize"
import { WithTooltip } from "../ui/with-tooltip"
import { MessageActions } from "./message-actions"
import { MessageMarkdown } from "./message-markdown"
const ICON_SIZE = 32
interface MessageProps {
message: Tables<"messages">
fileItems: Tables<"file_items">[]
isEditing: boolean
isLast: boolean
onStartEdit: (message: Tables<"messages">) => void
onCancelEdit: () => void
onSubmitEdit: (value: string, sequenceNumber: number) => void
}
export const Message: FC<MessageProps> = ({
message,
fileItems,
isEditing,
isLast,
onStartEdit,
onCancelEdit,
onSubmitEdit
}) => {
const {
assistants,
profile,
isGenerating,
setIsGenerating,
firstTokenReceived,
availableLocalModels,
availableOpenRouterModels,
chatMessages,
selectedAssistant,
chatImages,
assistantImages,
toolInUse,
files,
models
} = useContext(ChatbotUIContext)
const { handleSendMessage } = useChatHandler()
const editInputRef = useRef<HTMLTextAreaElement>(null)
const [isHovering, setIsHovering] = useState(false)
const [editedMessage, setEditedMessage] = useState(message.content)
const [showImagePreview, setShowImagePreview] = useState(false)
const [selectedImage, setSelectedImage] = useState<MessageImage | null>(null)
const [showFileItemPreview, setShowFileItemPreview] = useState(false)
const [selectedFileItem, setSelectedFileItem] =
useState<Tables<"file_items"> | null>(null)
const [viewSources, setViewSources] = useState(false)
const handleCopy = () => {
if (navigator.clipboard) {
navigator.clipboard.writeText(message.content)
} else {
const textArea = document.createElement("textarea")
textArea.value = message.content
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
document.execCommand("copy")
document.body.removeChild(textArea)
}
}
const handleSendEdit = () => {
onSubmitEdit(editedMessage, message.sequence_number)
onCancelEdit()
}
const handleKeyDown = (event: React.KeyboardEvent) => {
if (isEditing && event.key === "Enter" && event.metaKey) {
handleSendEdit()
}
}
const handleRegenerate = async () => {
setIsGenerating(true)
await handleSendMessage(
editedMessage || chatMessages[chatMessages.length - 2].message.content,
chatMessages,
true
)
}
const handleStartEdit = () => {
onStartEdit(message)
}
useEffect(() => {
setEditedMessage(message.content)
if (isEditing && editInputRef.current) {
const input = editInputRef.current
input.focus()
input.setSelectionRange(input.value.length, input.value.length)
}
}, [isEditing])
const MODEL_DATA = [
...models.map(model => ({
modelId: model.model_id as LLMID,
modelName: model.name,
provider: "custom" as ModelProvider,
hostedId: model.id,
platformLink: "",
imageInput: false
})),
...LLM_LIST,
...availableLocalModels,
...availableOpenRouterModels
].find(llm => llm.modelId === message.model) as LLM
const messageAssistantImage = assistantImages.find(
image => image.assistantId === message.assistant_id
)?.base64
const selectedAssistantImage = assistantImages.find(
image => image.path === selectedAssistant?.image_path
)?.base64
const modelDetails = LLM_LIST.find(model => model.modelId === message.model)
const fileAccumulator: Record<
string,
{
id: string
name: string
count: number
type: string
description: string
}
> = {}
const fileSummary = fileItems.reduce((acc, fileItem) => {
const parentFile = files.find(file => file.id === fileItem.file_id)
if (parentFile) {
if (!acc[parentFile.id]) {
acc[parentFile.id] = {
id: parentFile.id,
name: parentFile.name,
count: 1,
type: parentFile.type,
description: parentFile.description
}
} else {
acc[parentFile.id].count += 1
}
}
return acc
}, fileAccumulator)
return (
<div
className={cn(
"flex w-full justify-center",
message.role === "user" ? "" : "bg-secondary"
)}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
onKeyDown={handleKeyDown}
>
<div className="relative flex w-full flex-col p-6 sm:w-[550px] sm:px-0 md:w-[650px] lg:w-[650px] xl:w-[700px]">
<div className="absolute right-5 top-7 sm:right-0">
<MessageActions
onCopy={handleCopy}
onEdit={handleStartEdit}
isAssistant={message.role === "assistant"}
isLast={isLast}
isEditing={isEditing}
isHovering={isHovering}
onRegenerate={handleRegenerate}
/>
</div>
<div className="space-y-3">
{message.role === "system" ? (
<div className="flex items-center space-x-4">
<IconPencil
className="border-primary bg-primary text-secondary rounded border-DEFAULT p-1"
size={ICON_SIZE}
/>
<div className="text-lg font-semibold">Prompt</div>
</div>
) : (
<div className="flex items-center space-x-3">
{message.role === "assistant" ? (
messageAssistantImage ? (
<Image
style={{
width: `${ICON_SIZE}px`,
height: `${ICON_SIZE}px`
}}
className="rounded"
src={messageAssistantImage}
alt="assistant image"
height={ICON_SIZE}
width={ICON_SIZE}
/>
) : (
<WithTooltip
display={<div>{MODEL_DATA?.modelName}</div>}
trigger={
<ModelIcon
provider={modelDetails?.provider || "custom"}
height={ICON_SIZE}
width={ICON_SIZE}
/>
}
/>
)
) : profile?.image_url ? (
<Image
className={`size-[32px] rounded`}
src={profile?.image_url}
height={32}
width={32}
alt="user image"
/>
) : (
<IconMoodSmile
className="bg-primary text-secondary border-primary rounded border-DEFAULT p-1"
size={ICON_SIZE}
/>
)}
<div className="font-semibold">
{message.role === "assistant"
? message.assistant_id
? assistants.find(
assistant => assistant.id === message.assistant_id
)?.name
: selectedAssistant
? selectedAssistant?.name
: MODEL_DATA?.modelName
: profile?.display_name ?? profile?.username}
</div>
</div>
)}
{!firstTokenReceived &&
isGenerating &&
isLast &&
message.role === "assistant" ? (
<>
{(() => {
switch (toolInUse) {
case "none":
return (
<IconCircleFilled className="animate-pulse" size={20} />
)
case "retrieval":
return (
<div className="flex animate-pulse items-center space-x-2">
<IconFileText size={20} />
<div>Searching files...</div>
</div>
)
default:
return (
<div className="flex animate-pulse items-center space-x-2">
<IconBolt size={20} />
<div>Using {toolInUse}...</div>
</div>
)
}
})()}
</>
) : isEditing ? (
<TextareaAutosize
textareaRef={editInputRef}
className="text-md"
value={editedMessage}
onValueChange={setEditedMessage}
maxRows={20}
/>
) : (
<MessageMarkdown content={message.content} />
)}
</div>
{fileItems.length > 0 && (
<div className="border-primary mt-6 border-t pt-4 font-bold">
{!viewSources ? (
<div
className="flex cursor-pointer items-center text-lg hover:opacity-50"
onClick={() => setViewSources(true)}
>
{fileItems.length}
{fileItems.length > 1 ? " Sources " : " Source "}
from {Object.keys(fileSummary).length}{" "}
{Object.keys(fileSummary).length > 1 ? "Files" : "File"}{" "}
<IconCaretRightFilled className="ml-1" />
</div>
) : (
<>
<div
className="flex cursor-pointer items-center text-lg hover:opacity-50"
onClick={() => setViewSources(false)}
>
{fileItems.length}
{fileItems.length > 1 ? " Sources " : " Source "}
from {Object.keys(fileSummary).length}{" "}
{Object.keys(fileSummary).length > 1 ? "Files" : "File"}{" "}
<IconCaretDownFilled className="ml-1" />
</div>
<div className="mt-3 space-y-4">
{Object.values(fileSummary).map((file, index) => (
<div key={index}>
<div className="flex items-center space-x-2">
<div>
<FileIcon type={file.type} />
</div>
<div className="truncate">{file.name}</div>
</div>
{fileItems
.filter(fileItem => {
const parentFile = files.find(
parentFile => parentFile.id === fileItem.file_id
)
return parentFile?.id === file.id
})
.map((fileItem, index) => (
<div
key={index}
className="ml-8 mt-1.5 flex cursor-pointer items-center space-x-2 hover:opacity-50"
onClick={() => {
setSelectedFileItem(fileItem)
setShowFileItemPreview(true)
}}
>
<div className="text-sm font-normal">
<span className="mr-1 text-lg font-bold">-</span>{" "}
{fileItem.content.substring(0, 200)}...
</div>
</div>
))}
</div>
))}
</div>
</>
)}
</div>
)}
<div className="mt-3 flex flex-wrap gap-2">
{message.image_paths.map((path, index) => {
const item = chatImages.find(image => image.path === path)
return (
<Image
key={index}
className="cursor-pointer rounded hover:opacity-50"
src={path.startsWith("data") ? path : item?.base64}
alt="message image"
width={300}
height={300}
onClick={() => {
setSelectedImage({
messageId: message.id,
path,
base64: path.startsWith("data") ? path : item?.base64 || "",
url: path.startsWith("data") ? "" : item?.url || "",
file: null
})
setShowImagePreview(true)
}}
loading="lazy"
/>
)
})}
</div>
{isEditing && (
<div className="mt-4 flex justify-center space-x-2">
<Button size="sm" onClick={handleSendEdit}>
Save & Send
</Button>
<Button size="sm" variant="outline" onClick={onCancelEdit}>
Cancel
</Button>
</div>
)}
</div>
{showImagePreview && selectedImage && (
<FilePreview
type="image"
item={selectedImage}
isOpen={showImagePreview}
onOpenChange={(isOpen: boolean) => {
setShowImagePreview(isOpen)
setSelectedImage(null)
}}
/>
)}
{showFileItemPreview && selectedFileItem && (
<FilePreview
type="file_item"
item={selectedFileItem}
isOpen={showFileItemPreview}
onOpenChange={(isOpen: boolean) => {
setShowFileItemPreview(isOpen)
setSelectedFileItem(null)
}}
/>
)}
</div>
)
}

View File

@ -0,0 +1,107 @@
import { cn } from "@/lib/utils"
import mistral from "@/public/providers/mistral.png"
import groq from "@/public/providers/groq.png"
import perplexity from "@/public/providers/perplexity.png"
import { ModelProvider } from "@/types"
import { IconSparkles } from "@tabler/icons-react"
import { useTheme } from "next-themes"
import Image from "next/image"
import { FC, HTMLAttributes } from "react"
import { AnthropicSVG } from "../icons/anthropic-svg"
import { GoogleSVG } from "../icons/google-svg"
import { OpenAISVG } from "../icons/openai-svg"
interface ModelIconProps extends HTMLAttributes<HTMLDivElement> {
provider: ModelProvider
height: number
width: number
}
export const ModelIcon: FC<ModelIconProps> = ({
provider,
height,
width,
...props
}) => {
const { theme } = useTheme()
switch (provider as ModelProvider) {
case "openai":
return (
<OpenAISVG
className={cn(
"rounded-sm bg-white p-1 text-black",
props.className,
theme === "dark" ? "bg-white" : "border-DEFAULT border-black"
)}
width={width}
height={height}
/>
)
case "mistral":
return (
<Image
className={cn(
"rounded-sm p-1",
theme === "dark" ? "bg-white" : "border-DEFAULT border-black"
)}
src={mistral.src}
alt="Mistral"
width={width}
height={height}
/>
)
case "groq":
return (
<Image
className={cn(
"rounded-sm p-0",
theme === "dark" ? "bg-white" : "border-DEFAULT border-black"
)}
src={groq.src}
alt="Groq"
width={width}
height={height}
/>
)
case "anthropic":
return (
<AnthropicSVG
className={cn(
"rounded-sm bg-white p-1 text-black",
props.className,
theme === "dark" ? "bg-white" : "border-DEFAULT border-black"
)}
width={width}
height={height}
/>
)
case "google":
return (
<GoogleSVG
className={cn(
"rounded-sm bg-white p-1 text-black",
props.className,
theme === "dark" ? "bg-white" : "border-DEFAULT border-black"
)}
width={width}
height={height}
/>
)
case "perplexity":
return (
<Image
className={cn(
"rounded-sm p-1",
theme === "dark" ? "bg-white" : "border-DEFAULT border-black"
)}
src={perplexity.src}
alt="Mistral"
width={width}
height={height}
/>
)
default:
return <IconSparkles size={width} />
}
}

View File

@ -0,0 +1,49 @@
import { LLM } from "@/types"
import { FC } from "react"
import { ModelIcon } from "./model-icon"
import { IconInfoCircle } from "@tabler/icons-react"
import { WithTooltip } from "../ui/with-tooltip"
interface ModelOptionProps {
model: LLM
onSelect: () => void
}
export const ModelOption: FC<ModelOptionProps> = ({ model, onSelect }) => {
return (
<WithTooltip
display={
<div>
{model.provider !== "ollama" && model.pricing && (
<div className="space-y-1 text-sm">
<div>
<span className="font-semibold">Input Cost:</span>{" "}
{model.pricing.inputCost} {model.pricing.currency} per{" "}
{model.pricing.unit}
</div>
{model.pricing.outputCost && (
<div>
<span className="font-semibold">Output Cost:</span>{" "}
{model.pricing.outputCost} {model.pricing.currency} per{" "}
{model.pricing.unit}
</div>
)}
</div>
)}
</div>
}
side="bottom"
trigger={
<div
className="hover:bg-accent flex w-full cursor-pointer justify-start space-x-3 truncate rounded p-2 hover:opacity-50"
onClick={onSelect}
>
<div className="flex items-center space-x-2">
<ModelIcon provider={model.provider} width={28} height={28} />
<div className="text-sm font-semibold">{model.modelName}</div>
</div>
</div>
}
/>
)
}

View File

@ -0,0 +1,210 @@
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"
import { useTranslation } from 'react-i18next'
interface ModelSelectProps {
selectedModelId: string
onSelectModel: (modelId: LLMID) => void
}
export const ModelSelect: FC<ModelSelectProps> = ({
selectedModelId,
onSelectModel
}) => {
const { t } = useTranslation()
const {
profile,
models,
availableHostedModels,
availableLocalModels,
availableOpenRouterModels
} = useContext(ChatbotUIContext)
const inputRef = useRef<HTMLInputElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const [isOpen, setIsOpen] = useState(false)
const [search, setSearch] = useState("")
const [tab, setTab] = useState<"hosted" | "local">("hosted")
useEffect(() => {
if (isOpen) {
setTimeout(() => {
inputRef.current?.focus()
}, 100) // FIX: hacky
}
}, [isOpen])
const handleSelectModel = (modelId: LLMID) => {
onSelectModel(modelId)
setIsOpen(false)
}
const allModels = [
...models.map(model => ({
modelId: model.model_id as LLMID,
modelName: model.name,
provider: "custom" as ModelProvider,
hostedId: model.id,
platformLink: "",
imageInput: false
})),
...availableHostedModels,
...availableLocalModels,
...availableOpenRouterModels
]
const groupedModels = allModels.reduce<Record<string, LLM[]>>(
(groups, model) => {
const key = model.provider
if (!groups[key]) {
groups[key] = []
}
groups[key].push(model)
return groups
},
{}
)
const selectedModel = allModels.find(
model => model.modelId === selectedModelId
)
if (!profile) return null
return (
<DropdownMenu
open={isOpen}
onOpenChange={isOpen => {
setIsOpen(isOpen)
setSearch("")
}}
>
<DropdownMenuTrigger
className="bg-background w-full justify-start border-2 px-3 py-5"
asChild
disabled={allModels.length === 0}
>
{allModels.length === 0 ? (
<div className="rounded text-sm font-bold">
{t("chat.unlockModelsMessage")}
</div>
) : (
<Button
ref={triggerRef}
className="flex items-center justify-between"
variant="ghost"
>
<div className="flex items-center">
{selectedModel ? (
<>
<ModelIcon
provider={selectedModel?.provider}
width={26}
height={26}
/>
<div className="ml-2 flex items-center">
{selectedModel?.modelName}
</div>
</>
) : (
<div className="flex items-center">{t("chat.selectModel")}</div>
)}
</div>
<IconChevronDown />
</Button>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
className="space-y-2 overflow-auto p-2"
style={{ width: triggerRef.current?.offsetWidth }}
align="start"
>
<Tabs value={tab} onValueChange={(value: any) => setTab(value)}>
{availableLocalModels.length > 0 && (
<TabsList defaultValue="hosted" className="grid grid-cols-2">
<TabsTrigger value="hosted">{t("chat.hosted")}</TabsTrigger>
<TabsTrigger value="local">{t("chat.local")}</TabsTrigger>
</TabsList>
)}
</Tabs>
<Input
ref={inputRef}
className="w-full"
placeholder={t("chat.searchModelsPlaceholder")}
value={search}
onChange={e => setSearch(e.target.value)}
/>
<div className="max-h-[300px] overflow-auto">
{Object.entries(groupedModels).map(([provider, models]) => {
const filteredModels = models
.filter(model => {
if (tab === "hosted") return model.provider !== "ollama"
if (tab === "local") return model.provider === "ollama"
if (tab === "openrouter") return model.provider === "openrouter"
})
.filter(model =>
model.modelName.toLowerCase().includes(search.toLowerCase())
)
.sort((a, b) => a.provider.localeCompare(b.provider))
if (filteredModels.length === 0) return null
return (
<div key={provider}>
<div className="mb-1 ml-2 text-xs font-bold tracking-wide opacity-50">
{provider === "openai" && profile.use_azure_openai
? "AZURE OPENAI"
: provider === "custom"
? t("modelProvider.custom")
: provider.toUpperCase()}
</div>
<div className="mb-4">
{filteredModels.map(model => {
return (
<div
key={model.modelId}
className="flex items-center space-x-1"
>
{selectedModelId === model.modelId && (
<IconCheck className="ml-2" size={32} />
)}
<ModelOption
key={model.modelId}
model={model}
onSelect={() => handleSelectModel(model.modelId)}
/>
</div>
)
})}
</div>
</div>
)
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -0,0 +1,246 @@
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { FC } from "react"
import { Button } from "../ui/button"
import { useTranslation } from 'react-i18next'
interface APIStepProps {
openaiAPIKey: string
openaiOrgID: string
azureOpenaiAPIKey: string
azureOpenaiEndpoint: string
azureOpenai35TurboID: string
azureOpenai45TurboID: string
azureOpenai45VisionID: string
azureOpenaiEmbeddingsID: string
anthropicAPIKey: string
googleGeminiAPIKey: string
mistralAPIKey: string
groqAPIKey: string
perplexityAPIKey: string
useAzureOpenai: boolean
openrouterAPIKey: string
onOpenrouterAPIKeyChange: (value: string) => void
onOpenaiAPIKeyChange: (value: string) => void
onOpenaiOrgIDChange: (value: string) => void
onAzureOpenaiAPIKeyChange: (value: string) => void
onAzureOpenaiEndpointChange: (value: string) => void
onAzureOpenai35TurboIDChange: (value: string) => void
onAzureOpenai45TurboIDChange: (value: string) => void
onAzureOpenai45VisionIDChange: (value: string) => void
onAzureOpenaiEmbeddingsIDChange: (value: string) => void
onAnthropicAPIKeyChange: (value: string) => void
onGoogleGeminiAPIKeyChange: (value: string) => void
onMistralAPIKeyChange: (value: string) => void
onGroqAPIKeyChange: (value: string) => void
onPerplexityAPIKeyChange: (value: string) => void
onUseAzureOpenaiChange: (value: boolean) => void
}
export const APIStep: FC<APIStepProps> = ({
openaiAPIKey,
openaiOrgID,
azureOpenaiAPIKey,
azureOpenaiEndpoint,
azureOpenai35TurboID,
azureOpenai45TurboID,
azureOpenai45VisionID,
azureOpenaiEmbeddingsID,
anthropicAPIKey,
googleGeminiAPIKey,
mistralAPIKey,
groqAPIKey,
perplexityAPIKey,
openrouterAPIKey,
useAzureOpenai,
onOpenaiAPIKeyChange,
onOpenaiOrgIDChange,
onAzureOpenaiAPIKeyChange,
onAzureOpenaiEndpointChange,
onAzureOpenai35TurboIDChange,
onAzureOpenai45TurboIDChange,
onAzureOpenai45VisionIDChange,
onAzureOpenaiEmbeddingsIDChange,
onAnthropicAPIKeyChange,
onGoogleGeminiAPIKeyChange,
onMistralAPIKeyChange,
onGroqAPIKeyChange,
onPerplexityAPIKeyChange,
onUseAzureOpenaiChange,
onOpenrouterAPIKeyChange
}) => {
const { t } = useTranslation()
return (
<>
<div className="mt-5 space-y-2">
<Label className="flex items-center">
<div>
{useAzureOpenai ? t("setup.azureOpenaiApiKey") : t("setup.openaiApiKey")}
</div>
<Button
className="ml-3 h-[18px] w-[150px] text-[11px]"
onClick={() => onUseAzureOpenaiChange(!useAzureOpenai)}
>
{useAzureOpenai
? t("setup.switchToOpenAI")
: t("setup.switchToAzureOpenAI")}
</Button>
</Label>
<Input
placeholder={
useAzureOpenai ? t("setup.azureOpenaiApiKey") : t("setup.openaiApiKey")
}
type="password"
value={useAzureOpenai ? azureOpenaiAPIKey : openaiAPIKey}
onChange={e =>
useAzureOpenai
? onAzureOpenaiAPIKeyChange(e.target.value)
: onOpenaiAPIKeyChange(e.target.value)
}
/>
</div>
<div className="ml-8 space-y-3">
{useAzureOpenai ? (
<>
<div className="space-y-1">
<Label>{t("setup.azureOpenaiEndpoint")}</Label>
<Input
placeholder="https://your-endpoint.openai.azure.com"
type="password"
value={azureOpenaiEndpoint}
onChange={e => onAzureOpenaiEndpointChange(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label>{t("setup.azureOpenai35TurboId")}</Label>
<Input
placeholder={t("setup.azureOpenai35TurboId")}
type="password"
value={azureOpenai35TurboID}
onChange={e => onAzureOpenai35TurboIDChange(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label>{t("setup.azureOpenai45TurboId")}</Label>
<Input
placeholder={t("setup.azureOpenai45TurboId")}
type="password"
value={azureOpenai45TurboID}
onChange={e => onAzureOpenai45TurboIDChange(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label>{t("setup.azureOpenai45VisionId")}</Label>
<Input
placeholder={t("setup.azureOpenai45VisionId")}
type="password"
value={azureOpenai45VisionID}
onChange={e => onAzureOpenai45VisionIDChange(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label>{t("setup.azureOpenaiEmbeddingsId")}</Label>
<Input
placeholder={t("setup.azureOpenaiEmbeddingsId")}
type="password"
value={azureOpenaiEmbeddingsID}
onChange={e => onAzureOpenaiEmbeddingsIDChange(e.target.value)}
/>
</div>
</>
) : (
<>
<div className="space-y-1">
<Label>{t("setup.openaiOrgId")}</Label>
<Input
placeholder={t("setup.openaiOrgIdOptional")}
type="password"
value={openaiOrgID}
onChange={e => onOpenaiOrgIDChange(e.target.value)}
/>
</div>
</>
)}
</div>
<div className="space-y-1">
<Label>{t("setup.anthropicApiKey")}</Label>
<Input
placeholder={t("setup.anthropicApiKey")}
type="password"
value={anthropicAPIKey}
onChange={e => onAnthropicAPIKeyChange(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label>{t("setup.googleGeminiApiKey")}</Label>
<Input
placeholder={t("setup.googleGeminiApiKey")}
type="password"
value={googleGeminiAPIKey}
onChange={e => onGoogleGeminiAPIKeyChange(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label>{t("setup.mistralApiKey")}</Label>
<Input
placeholder={t("setup.mistralApiKey")}
type="password"
value={mistralAPIKey}
onChange={e => onMistralAPIKeyChange(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label>{t("setup.groqApiKey")}</Label>
<Input
placeholder={t("setup.groqApiKey")}
type="password"
value={groqAPIKey}
onChange={e => onGroqAPIKeyChange(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label>{t("setup.perplexityApiKey")}</Label>
<Input
placeholder={t("setup.perplexityApiKey")}
type="password"
value={perplexityAPIKey}
onChange={e => onPerplexityAPIKeyChange(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label>{t("setup.openrouterApiKey")}</Label>
<Input
placeholder={t("setup.openrouterApiKey")}
type="password"
value={openrouterAPIKey}
onChange={e => onOpenrouterAPIKeyChange(e.target.value)}
/>
</div>
</>
)
}

View File

@ -0,0 +1,20 @@
import { FC } from "react"
import { useTranslation } from 'react-i18next'
interface FinishStepProps {
displayName: string
}
export const FinishStep: FC<FinishStepProps> = ({ displayName }) => {
const { t } = useTranslation()
return (
<div className="space-y-4">
<div>
{t("setup.WelcomeToChatAIUI")}
{displayName.length > 0 ? `, ${displayName.split(" ")[0]}` : null}!
</div>
<div>{t("setup.ClickNextToStartChatting")}</div>
</div>
)
}

View File

@ -0,0 +1,154 @@
import { useTranslation } from "react-i18next" // 导入 useTranslation
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
PROFILE_DISPLAY_NAME_MAX,
PROFILE_USERNAME_MAX,
PROFILE_USERNAME_MIN
} from "@/db/limits"
import {
IconCircleCheckFilled,
IconCircleXFilled,
IconLoader2
} from "@tabler/icons-react"
import { FC, useCallback, useState } from "react"
import { LimitDisplay } from "../ui/limit-display"
import { toast } from "sonner"
interface ProfileStepProps {
username: string
usernameAvailable: boolean
displayName: string
onUsernameAvailableChange: (isAvailable: boolean) => void
onUsernameChange: (username: string) => void
onDisplayNameChange: (name: string) => void
}
export const ProfileStep: FC<ProfileStepProps> = ({
username,
usernameAvailable,
displayName,
onUsernameAvailableChange,
onUsernameChange,
onDisplayNameChange
}) => {
const { t } = useTranslation() // 使用 t 函数来获取翻译内容
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(
t("login.usernameError")
//"Username must be letters, numbers, or underscores only - no other characters or spacing allowed."
)
return
}
setLoading(true)
const response = await fetch(`/api/username/available`, {
method: "POST",
body: JSON.stringify({ username })
})
const data = await response.json()
const isAvailable = data.isAvailable
onUsernameAvailableChange(isAvailable)
setLoading(false)
}, 500),
[]
)
return (
<>
<div className="space-y-1">
<div className="flex items-center space-x-2">
<Label>{t("login.username")}</Label>
<div className="text-xs">
{usernameAvailable ? (
<div className="text-green-500">{t("login.available")}</div>
) : (
<div className="text-red-500">{t("login.unavailable")}</div>
)}
</div>
</div>
<div className="relative">
<Input
className="pr-10"
placeholder={t("login.usernamePlaceholder")}
value={username}
onChange={e => {
onUsernameChange(e.target.value)
checkUsernameAvailability(e.target.value)
}}
minLength={PROFILE_USERNAME_MIN}
maxLength={PROFILE_USERNAME_MAX}
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
{loading ? (
<IconLoader2 className="animate-spin" />
) : usernameAvailable ? (
<IconCircleCheckFilled className="text-green-500" />
) : (
<IconCircleXFilled className="text-red-500" />
)}
</div>
</div>
<LimitDisplay used={username.length} limit={PROFILE_USERNAME_MAX} />
</div>
<div className="space-y-1">
<Label>{t("login.chatDisplayName")}</Label>
<Input
placeholder={t("login.displayNamePlaceholder")}
value={displayName}
onChange={e => onDisplayNameChange(e.target.value)}
maxLength={PROFILE_DISPLAY_NAME_MAX}
/>
<LimitDisplay
used={displayName.length}
limit={PROFILE_DISPLAY_NAME_MAX}
/>
</div>
</>
)
}

View File

@ -0,0 +1,91 @@
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle
} from "@/components/ui/card"
import { FC, useRef } from "react"
import { useTranslation } from 'react-i18next'
export const SETUP_STEP_COUNT = 3
interface StepContainerProps {
stepDescription: string
stepNum: number
stepTitle: string
onShouldProceed: (shouldProceed: boolean) => void
children?: React.ReactNode
showBackButton?: boolean
showNextButton?: boolean
}
export const StepContainer: FC<StepContainerProps> = ({
stepDescription,
stepNum,
stepTitle,
onShouldProceed,
children,
showBackButton = false,
showNextButton = true
}) => {
const buttonRef = useRef<HTMLButtonElement>(null)
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
if (buttonRef.current) {
buttonRef.current.click()
}
}
}
const { t } = useTranslation()
return (
<Card
className="max-h-[calc(100vh-60px)] w-[600px] overflow-auto"
onKeyDown={handleKeyDown}
>
<CardHeader>
<CardTitle className="flex justify-between">
<div>{stepTitle}</div>
<div className="text-sm">
{stepNum} / {SETUP_STEP_COUNT}
</div>
</CardTitle>
<CardDescription>{stepDescription}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">{children}</CardContent>
<CardFooter className="flex justify-between">
<div>
{showBackButton && (
<Button
size="sm"
variant="outline"
onClick={() => onShouldProceed(false)}
>
{t("setup.back")}
</Button>
)}
</div>
<div>
{showNextButton && (
<Button
ref={buttonRef}
size="sm"
onClick={() => onShouldProceed(true)}
>
{t("setup.next")}
</Button>
)}
</div>
</CardFooter>
</Card>
)
}

View File

@ -0,0 +1,278 @@
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"
import { useTranslation } from 'react-i18next'
interface SidebarCreateItemProps {
isOpen: boolean
isTyping: boolean
onOpenChange: (isOpen: boolean) => void
contentType: ContentType
renderInputs: () => JSX.Element
createState: any
}
export const SidebarCreateItem: FC<SidebarCreateItemProps> = ({
isOpen,
onOpenChange,
contentType,
renderInputs,
createState,
isTyping
}) => {
const { t, i18n } = useTranslation()
const {
selectedWorkspace,
setChats,
setPresets,
setPrompts,
setFiles,
setCollections,
setAssistants,
setAssistantImages,
setTools,
setModels
} = useContext(ChatbotUIContext)
const buttonRef = useRef<HTMLButtonElement>(null)
const [creating, setCreating] = useState(false)
const createFunctions = {
chats: createChat,
presets: createPreset,
prompts: createPrompt,
files: async (
createState: { file: File } & TablesInsert<"files">,
workspaceId: string
) => {
if (!selectedWorkspace) return
const { file, ...rest } = createState
const createdFile = await createFileBasedOnExtension(
file,
rest,
workspaceId,
selectedWorkspace.embeddings_provider as "openai" | "local"
)
return createdFile
},
collections: async (
createState: {
image: File
collectionFiles: TablesInsert<"collection_files">[]
} & Tables<"collections">,
workspaceId: string
) => {
const { collectionFiles, ...rest } = createState
const createdCollection = await createCollection(rest, workspaceId)
const finalCollectionFiles = collectionFiles.map(collectionFile => ({
...collectionFile,
collection_id: createdCollection.id
}))
await createCollectionFiles(finalCollectionFiles)
return createdCollection
},
assistants: async (
createState: {
image: File
files: Tables<"files">[]
collections: Tables<"collections">[]
tools: Tables<"tools">[]
} & Tables<"assistants">,
workspaceId: string
) => {
const { image, files, collections, tools, ...rest } = createState
const createdAssistant = await createAssistant(rest, workspaceId)
let updatedAssistant = createdAssistant
if (image) {
const filePath = await uploadAssistantImage(createdAssistant, image)
updatedAssistant = await updateAssistant(createdAssistant.id, {
image_path: filePath
})
const url = (await getAssistantImageFromStorage(filePath)) || ""
if (url) {
const response = await fetch(url)
const blob = await response.blob()
const base64 = await convertBlobToBase64(blob)
setAssistantImages(prev => [
...prev,
{
assistantId: updatedAssistant.id,
path: filePath,
base64,
url
}
])
}
}
const assistantFiles = files.map(file => ({
user_id: rest.user_id,
assistant_id: createdAssistant.id,
file_id: file.id
}))
const assistantCollections = collections.map(collection => ({
user_id: rest.user_id,
assistant_id: createdAssistant.id,
collection_id: collection.id
}))
const assistantTools = tools.map(tool => ({
user_id: rest.user_id,
assistant_id: createdAssistant.id,
tool_id: tool.id
}))
await createAssistantFiles(assistantFiles)
await createAssistantCollections(assistantCollections)
await createAssistantTools(assistantTools)
return updatedAssistant
},
tools: createTool,
models: createModel
}
const stateUpdateFunctions = {
chats: setChats,
presets: setPresets,
prompts: setPrompts,
files: setFiles,
collections: setCollections,
assistants: setAssistants,
tools: setTools,
models: setModels
}
const handleCreate = async () => {
try {
if (!selectedWorkspace) return
if (isTyping) return // Prevent creation while typing
const createFunction = createFunctions[contentType]
const setStateFunction = stateUpdateFunctions[contentType]
if (!createFunction || !setStateFunction) return
setCreating(true)
const newItem = await createFunction(createState, selectedWorkspace.id)
setStateFunction((prevItems: any) => [...prevItems, newItem])
onOpenChange(false)
setCreating(false)
} catch (error) {
toast.error(`Error creating ${contentType.slice(0, -1)}. ${error}.`)
setCreating(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (!isTyping && e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
buttonRef.current?.click()
}
}
// 判断是否需要首字母大写(且做 -1 截断)
const needsUpperCaseFirstLetter = (language: string) => {
const languagesRequiringUpperCase = ['en', 'de', 'fr', 'es', 'it'];
return languagesRequiringUpperCase.includes(language);
};
// 处理翻译后的 contentType 文本
const getCapitalizedContentType = (translated: string, language: string) => {
if (needsUpperCaseFirstLetter(language)) {
return translated.charAt(0).toUpperCase() + translated.slice(1, -1); // ✅ 按你的要求保留 .slice(1, -1)
}
return translated;
};
return (
<Sheet open={isOpen} onOpenChange={onOpenChange}>
<SheetContent
className="flex min-w-[450px] flex-col justify-between overflow-auto"
side="left"
onKeyDown={handleKeyDown}
>
<div className="grow overflow-auto">
<SheetHeader>
<SheetTitle className="text-2xl font-bold">
{/* Create{" "}
{contentType.charAt(0).toUpperCase() + contentType.slice(1, -1)} */}
{t("side.sidebarCreateNew")}{" "}
{getCapitalizedContentType(t(`contentType.${contentType}`), i18n.language)}
</SheetTitle>
</SheetHeader>
<div className="mt-4 space-y-3">{renderInputs()}</div>
</div>
<SheetFooter className="mt-2 flex justify-between">
<div className="flex grow justify-end space-x-2">
<Button
disabled={creating}
variant="outline"
onClick={() => onOpenChange(false)}
>
{t("side.cancel")}
</Button>
<Button disabled={creating} ref={buttonRef} onClick={handleCreate}>
{creating ? t("side.creating") : t("side.create")}
</Button>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
)
}

View File

@ -0,0 +1,147 @@
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"
import { useTranslation } from 'react-i18next'
interface SidebarDeleteItemProps {
item: DataItemType
contentType: ContentType
}
export const SidebarDeleteItem: FC<SidebarDeleteItemProps> = ({
item,
contentType
}) => {
const { t } = useTranslation()
const {
setChats,
setPresets,
setPrompts,
setFiles,
setCollections,
setAssistants,
setTools,
setModels
} = useContext(ChatbotUIContext)
const buttonRef = useRef<HTMLButtonElement>(null)
const [showDialog, setShowDialog] = useState(false)
const deleteFunctions = {
chats: async (chat: Tables<"chats">) => {
await deleteChat(chat.id)
},
presets: async (preset: Tables<"presets">) => {
await deletePreset(preset.id)
},
prompts: async (prompt: Tables<"prompts">) => {
await deletePrompt(prompt.id)
},
files: async (file: Tables<"files">) => {
await deleteFileFromStorage(file.file_path)
await deleteFile(file.id)
},
collections: async (collection: Tables<"collections">) => {
await deleteCollection(collection.id)
},
assistants: async (assistant: Tables<"assistants">) => {
await deleteAssistant(assistant.id)
setChats(prevState =>
prevState.filter(chat => chat.assistant_id !== assistant.id)
)
},
tools: async (tool: Tables<"tools">) => {
await deleteTool(tool.id)
},
models: async (model: Tables<"models">) => {
await deleteModel(model.id)
}
}
const stateUpdateFunctions = {
chats: setChats,
presets: setPresets,
prompts: setPrompts,
files: setFiles,
collections: setCollections,
assistants: setAssistants,
tools: setTools,
models: setModels
}
const handleDelete = async () => {
const deleteFunction = deleteFunctions[contentType]
const setStateFunction = stateUpdateFunctions[contentType]
if (!deleteFunction || !setStateFunction) return
await deleteFunction(item as any)
setStateFunction((prevItems: any) =>
prevItems.filter((prevItem: any) => prevItem.id !== item.id)
)
setShowDialog(false)
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter") {
e.stopPropagation()
buttonRef.current?.click()
}
}
return (
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogTrigger asChild>
<Button className="text-red-500" variant="ghost">
{t("side.delete")}
</Button>
</DialogTrigger>
<DialogContent onKeyDown={handleKeyDown}>
<DialogHeader>
<DialogTitle>{t("side.delete")} {contentType.slice(0, -1)}</DialogTitle>
<DialogDescription>
{t("side.confirmDelete")} {item.name}?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setShowDialog(false)}>
{t("side.cancel")}
</Button>
<Button ref={buttonRef} variant="destructive" onClick={handleDelete}>
{t("side.delete")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,151 @@
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"
import { usePathname } from "next/navigation"
import i18nConfig from "@/i18nConfig"
interface SidebarItemProps {
item: DataItemType
isTyping: boolean
contentType: ContentType
icon: React.ReactNode
updateState: any
renderInputs: (renderState: any) => JSX.Element
}
export const SidebarItem: FC<SidebarItemProps> = ({
item,
contentType,
updateState,
renderInputs,
icon,
isTyping
}) => {
const { selectedWorkspace, setChats, setSelectedAssistant } =
useContext(ChatbotUIContext)
const pathname = usePathname() // 获取当前路径
const pathSegments = pathname.split("/").filter(Boolean)
const locales = i18nConfig.locales
const defaultLocale = i18nConfig.defaultLocale
let locale: (typeof locales)[number] = defaultLocale
const segment = pathSegments[0] as (typeof locales)[number]
if (locales.includes(segment)) {
locale = segment
}
const homePath = locale === defaultLocale ? "/" : `/${locale}`
const router = useRouter()
const itemRef = useRef<HTMLDivElement>(null)
const [isHovering, setIsHovering] = useState(false)
const actionMap = {
chats: async (item: any) => {},
presets: async (item: any) => {},
prompts: async (item: any) => {},
files: async (item: any) => {},
collections: async (item: any) => {},
assistants: async (assistant: Tables<"assistants">) => {
if (!selectedWorkspace) return
const createdChat = await createChat({
user_id: assistant.user_id,
workspace_id: selectedWorkspace.id,
assistant_id: assistant.id,
context_length: assistant.context_length,
include_profile_context: assistant.include_profile_context,
include_workspace_instructions:
assistant.include_workspace_instructions,
model: assistant.model,
name: `Chat with ${assistant.name}`,
prompt: assistant.prompt,
temperature: assistant.temperature,
embeddings_provider: assistant.embeddings_provider
})
setChats(prevState => [createdChat, ...prevState])
setSelectedAssistant(assistant)
return router.push(`${homePath}/${selectedWorkspace.id}/chat/${createdChat.id}`)
// return router.push(`/${locale}/${selectedWorkspace.id}/chat/${createdChat.id}`)
},
tools: async (item: any) => {},
models: async (item: any) => {}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter") {
e.stopPropagation()
itemRef.current?.click()
}
}
// const handleClickAction = async (
// e: React.MouseEvent<SVGSVGElement, MouseEvent>
// ) => {
// e.stopPropagation()
// const action = actionMap[contentType]
// await action(item as any)
// }
return (
<SidebarUpdateItem
item={item}
isTyping={isTyping}
contentType={contentType}
updateState={updateState}
renderInputs={renderInputs}
>
<div
ref={itemRef}
className={cn(
"hover:bg-accent flex w-full cursor-pointer items-center rounded p-2 hover:opacity-50 focus:outline-none"
)}
tabIndex={0}
onKeyDown={handleKeyDown}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
{icon}
<div className="ml-3 flex-1 truncate text-sm font-semibold">
{item.name}
</div>
{/* TODO */}
{/* {isHovering && (
<WithTooltip
delayDuration={1000}
display={<div>Start chat with {contentType.slice(0, -1)}</div>}
trigger={
<IconSquarePlus
className="cursor-pointer hover:text-blue-500"
size={20}
onClick={handleClickAction}
/>
}
/>
)} */}
</div>
</SidebarUpdateItem>
)
}

View File

@ -0,0 +1,684 @@
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"
import { useTranslation } from 'react-i18next'
interface SidebarUpdateItemProps {
isTyping: boolean
item: DataItemType
contentType: ContentType
children: React.ReactNode
renderInputs: (renderState: any) => JSX.Element
updateState: any
}
export const SidebarUpdateItem: FC<SidebarUpdateItemProps> = ({
item,
contentType,
children,
renderInputs,
updateState,
isTyping
}) => {
const { t } = useTranslation()
const {
workspaces,
selectedWorkspace,
setChats,
setPresets,
setPrompts,
setFiles,
setCollections,
setAssistants,
setTools,
setModels,
setAssistantImages
} = useContext(ChatbotUIContext)
const buttonRef = useRef<HTMLButtonElement>(null)
const [isOpen, setIsOpen] = useState(false)
const [startingWorkspaces, setStartingWorkspaces] = useState<
Tables<"workspaces">[]
>([])
const [selectedWorkspaces, setSelectedWorkspaces] = useState<
Tables<"workspaces">[]
>([])
// Collections Render State
const [startingCollectionFiles, setStartingCollectionFiles] = useState<
CollectionFile[]
>([])
const [selectedCollectionFiles, setSelectedCollectionFiles] = useState<
CollectionFile[]
>([])
// Assistants Render State
const [startingAssistantFiles, setStartingAssistantFiles] = useState<
Tables<"files">[]
>([])
const [startingAssistantCollections, setStartingAssistantCollections] =
useState<Tables<"collections">[]>([])
const [startingAssistantTools, setStartingAssistantTools] = useState<
Tables<"tools">[]
>([])
const [selectedAssistantFiles, setSelectedAssistantFiles] = useState<
Tables<"files">[]
>([])
const [selectedAssistantCollections, setSelectedAssistantCollections] =
useState<Tables<"collections">[]>([])
const [selectedAssistantTools, setSelectedAssistantTools] = useState<
Tables<"tools">[]
>([])
useEffect(() => {
if (isOpen) {
const fetchData = async () => {
if (workspaces.length > 1) {
const workspaces = await fetchSelectedWorkspaces()
setStartingWorkspaces(workspaces)
setSelectedWorkspaces(workspaces)
}
const fetchDataFunction = fetchDataFunctions[contentType]
if (!fetchDataFunction) return
await fetchDataFunction(item.id)
}
fetchData()
}
}, [isOpen])
const renderState = {
chats: null,
presets: null,
prompts: null,
files: null,
collections: {
startingCollectionFiles,
setStartingCollectionFiles,
selectedCollectionFiles,
setSelectedCollectionFiles
},
assistants: {
startingAssistantFiles,
setStartingAssistantFiles,
startingAssistantCollections,
setStartingAssistantCollections,
startingAssistantTools,
setStartingAssistantTools,
selectedAssistantFiles,
setSelectedAssistantFiles,
selectedAssistantCollections,
setSelectedAssistantCollections,
selectedAssistantTools,
setSelectedAssistantTools
},
tools: null,
models: null
}
const fetchDataFunctions = {
chats: null,
presets: null,
prompts: null,
files: null,
collections: async (collectionId: string) => {
const collectionFiles =
await getCollectionFilesByCollectionId(collectionId)
setStartingCollectionFiles(collectionFiles.files)
setSelectedCollectionFiles([])
},
assistants: async (assistantId: string) => {
const assistantFiles = await getAssistantFilesByAssistantId(assistantId)
setStartingAssistantFiles(assistantFiles.files)
const assistantCollections =
await getAssistantCollectionsByAssistantId(assistantId)
setStartingAssistantCollections(assistantCollections.collections)
const assistantTools = await getAssistantToolsByAssistantId(assistantId)
setStartingAssistantTools(assistantTools.tools)
setSelectedAssistantFiles([])
setSelectedAssistantCollections([])
setSelectedAssistantTools([])
},
tools: null,
models: null
}
const fetchWorkpaceFunctions = {
chats: null,
presets: async (presetId: string) => {
const item = await getPresetWorkspacesByPresetId(presetId)
return item.workspaces
},
prompts: async (promptId: string) => {
const item = await getPromptWorkspacesByPromptId(promptId)
return item.workspaces
},
files: async (fileId: string) => {
const item = await getFileWorkspacesByFileId(fileId)
return item.workspaces
},
collections: async (collectionId: string) => {
const item = await getCollectionWorkspacesByCollectionId(collectionId)
return item.workspaces
},
assistants: async (assistantId: string) => {
const item = await getAssistantWorkspacesByAssistantId(assistantId)
return item.workspaces
},
tools: async (toolId: string) => {
const item = await getToolWorkspacesByToolId(toolId)
return item.workspaces
},
models: async (modelId: string) => {
const item = await getModelWorkspacesByModelId(modelId)
return item.workspaces
}
}
const fetchSelectedWorkspaces = async () => {
const fetchFunction = fetchWorkpaceFunctions[contentType]
if (!fetchFunction) return []
const workspaces = await fetchFunction(item.id)
return workspaces
}
const handleWorkspaceUpdates = async (
startingWorkspaces: Tables<"workspaces">[],
selectedWorkspaces: Tables<"workspaces">[],
itemId: string,
deleteWorkspaceFn: (
itemId: string,
workspaceId: string
) => Promise<boolean>,
createWorkspaceFn: (
workspaces: { user_id: string; item_id: string; workspace_id: string }[]
) => Promise<void>,
itemIdKey: string
) => {
if (!selectedWorkspace) return
const deleteList = startingWorkspaces.filter(
startingWorkspace =>
!selectedWorkspaces.some(
selectedWorkspace => selectedWorkspace.id === startingWorkspace.id
)
)
for (const workspace of deleteList) {
await deleteWorkspaceFn(itemId, workspace.id)
}
if (deleteList.map(w => w.id).includes(selectedWorkspace.id)) {
const setStateFunction = stateUpdateFunctions[contentType]
if (setStateFunction) {
setStateFunction((prevItems: any) =>
prevItems.filter((prevItem: any) => prevItem.id !== item.id)
)
}
}
const createList = selectedWorkspaces.filter(
selectedWorkspace =>
!startingWorkspaces.some(
startingWorkspace => startingWorkspace.id === selectedWorkspace.id
)
)
await createWorkspaceFn(
createList.map(workspace => {
return {
user_id: workspace.user_id,
[itemIdKey]: itemId,
workspace_id: workspace.id
} as any
})
)
}
const updateFunctions = {
chats: updateChat,
presets: async (presetId: string, updateState: TablesUpdate<"presets">) => {
const updatedPreset = await updatePreset(presetId, updateState)
await handleWorkspaceUpdates(
startingWorkspaces,
selectedWorkspaces,
presetId,
deletePresetWorkspace,
createPresetWorkspaces as any,
"preset_id"
)
return updatedPreset
},
prompts: async (promptId: string, updateState: TablesUpdate<"prompts">) => {
const updatedPrompt = await updatePrompt(promptId, updateState)
await handleWorkspaceUpdates(
startingWorkspaces,
selectedWorkspaces,
promptId,
deletePromptWorkspace,
createPromptWorkspaces as any,
"prompt_id"
)
return updatedPrompt
},
files: async (fileId: string, updateState: TablesUpdate<"files">) => {
const updatedFile = await updateFile(fileId, updateState)
await handleWorkspaceUpdates(
startingWorkspaces,
selectedWorkspaces,
fileId,
deleteFileWorkspace,
createFileWorkspaces as any,
"file_id"
)
return updatedFile
},
collections: async (
collectionId: string,
updateState: TablesUpdate<"assistants">
) => {
if (!profile) return
const { ...rest } = updateState
const filesToAdd = selectedCollectionFiles.filter(
selectedFile =>
!startingCollectionFiles.some(
startingFile => startingFile.id === selectedFile.id
)
)
const filesToRemove = startingCollectionFiles.filter(startingFile =>
selectedCollectionFiles.some(
selectedFile => selectedFile.id === startingFile.id
)
)
for (const file of filesToAdd) {
await createCollectionFile({
user_id: item.user_id,
collection_id: collectionId,
file_id: file.id
})
}
for (const file of filesToRemove) {
await deleteCollectionFile(collectionId, file.id)
}
const updatedCollection = await updateCollection(collectionId, rest)
await handleWorkspaceUpdates(
startingWorkspaces,
selectedWorkspaces,
collectionId,
deleteCollectionWorkspace,
createCollectionWorkspaces as any,
"collection_id"
)
return updatedCollection
},
assistants: async (
assistantId: string,
updateState: {
assistantId: string
image: File
} & TablesUpdate<"assistants">
) => {
const { image, ...rest } = updateState
const filesToAdd = selectedAssistantFiles.filter(
selectedFile =>
!startingAssistantFiles.some(
startingFile => startingFile.id === selectedFile.id
)
)
const filesToRemove = startingAssistantFiles.filter(startingFile =>
selectedAssistantFiles.some(
selectedFile => selectedFile.id === startingFile.id
)
)
for (const file of filesToAdd) {
await createAssistantFile({
user_id: item.user_id,
assistant_id: assistantId,
file_id: file.id
})
}
for (const file of filesToRemove) {
await deleteAssistantFile(assistantId, file.id)
}
const collectionsToAdd = selectedAssistantCollections.filter(
selectedCollection =>
!startingAssistantCollections.some(
startingCollection =>
startingCollection.id === selectedCollection.id
)
)
const collectionsToRemove = startingAssistantCollections.filter(
startingCollection =>
selectedAssistantCollections.some(
selectedCollection =>
selectedCollection.id === startingCollection.id
)
)
for (const collection of collectionsToAdd) {
await createAssistantCollection({
user_id: item.user_id,
assistant_id: assistantId,
collection_id: collection.id
})
}
for (const collection of collectionsToRemove) {
await deleteAssistantCollection(assistantId, collection.id)
}
const toolsToAdd = selectedAssistantTools.filter(
selectedTool =>
!startingAssistantTools.some(
startingTool => startingTool.id === selectedTool.id
)
)
const toolsToRemove = startingAssistantTools.filter(startingTool =>
selectedAssistantTools.some(
selectedTool => selectedTool.id === startingTool.id
)
)
for (const tool of toolsToAdd) {
await createAssistantTool({
user_id: item.user_id,
assistant_id: assistantId,
tool_id: tool.id
})
}
for (const tool of toolsToRemove) {
await deleteAssistantTool(assistantId, tool.id)
}
let updatedAssistant = await updateAssistant(assistantId, rest)
if (image) {
const filePath = await uploadAssistantImage(updatedAssistant, image)
updatedAssistant = await updateAssistant(assistantId, {
image_path: filePath
})
const url = (await getAssistantImageFromStorage(filePath)) || ""
if (url) {
const response = await fetch(url)
const blob = await response.blob()
const base64 = await convertBlobToBase64(blob)
setAssistantImages(prev => [
...prev,
{
assistantId: updatedAssistant.id,
path: filePath,
base64,
url
}
])
}
}
await handleWorkspaceUpdates(
startingWorkspaces,
selectedWorkspaces,
assistantId,
deleteAssistantWorkspace,
createAssistantWorkspaces as any,
"assistant_id"
)
return updatedAssistant
},
tools: async (toolId: string, updateState: TablesUpdate<"tools">) => {
const updatedTool = await updateTool(toolId, updateState)
await handleWorkspaceUpdates(
startingWorkspaces,
selectedWorkspaces,
toolId,
deleteToolWorkspace,
createToolWorkspaces as any,
"tool_id"
)
return updatedTool
},
models: async (modelId: string, updateState: TablesUpdate<"models">) => {
const updatedModel = await updateModel(modelId, updateState)
await handleWorkspaceUpdates(
startingWorkspaces,
selectedWorkspaces,
modelId,
deleteModelWorkspace,
createModelWorkspaces as any,
"model_id"
)
return updatedModel
}
}
const stateUpdateFunctions = {
chats: setChats,
presets: setPresets,
prompts: setPrompts,
files: setFiles,
collections: setCollections,
assistants: setAssistants,
tools: setTools,
models: setModels
}
const handleUpdate = async () => {
try {
const updateFunction = updateFunctions[contentType]
const setStateFunction = stateUpdateFunctions[contentType]
if (!updateFunction || !setStateFunction) return
if (isTyping) return // Prevent update while typing
const updatedItem = await updateFunction(item.id, updateState)
setStateFunction((prevItems: any) =>
prevItems.map((prevItem: any) =>
prevItem.id === item.id ? updatedItem : prevItem
)
)
setIsOpen(false)
toast.success(`${contentType.slice(0, -1)} updated successfully`)
} catch (error) {
toast.error(`Error updating ${contentType.slice(0, -1)}. ${error}`)
}
}
const handleSelectWorkspace = (workspace: Tables<"workspaces">) => {
setSelectedWorkspaces(prevState => {
const isWorkspaceAlreadySelected = prevState.find(
selectedWorkspace => selectedWorkspace.id === workspace.id
)
if (isWorkspaceAlreadySelected) {
return prevState.filter(
selectedWorkspace => selectedWorkspace.id !== workspace.id
)
} else {
return [...prevState, workspace]
}
})
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (!isTyping && e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
buttonRef.current?.click()
}
}
return (
<Sheet open={isOpen} onOpenChange={setIsOpen}>
<SheetTrigger asChild>{children}</SheetTrigger>
<SheetContent
className="flex min-w-[450px] flex-col justify-between"
side="left"
onKeyDown={handleKeyDown}
>
<div className="grow overflow-auto">
<SheetHeader>
<SheetTitle className="text-2xl font-bold">
{t("side.edit")} {contentType.slice(0, -1)}
</SheetTitle>
</SheetHeader>
<div className="mt-4 space-y-3">
{workspaces.length > 1 && (
<div className="space-y-1">
<Label>{t("side.assignedWorkspaces")}</Label>
<AssignWorkspaces
selectedWorkspaces={selectedWorkspaces}
onSelectWorkspace={handleSelectWorkspace}
/>
</div>
)}
{renderInputs(renderState[contentType])}
</div>
</div>
<SheetFooter className="mt-2 flex justify-between">
<SidebarDeleteItem item={item} contentType={contentType} />
<div className="flex grow justify-end space-x-2">
<Button variant="outline" onClick={() => setIsOpen(false)}>
{t("side.cancel")}
</Button>
<Button ref={buttonRef} onClick={handleUpdate}>
{t("side.save")}
</Button>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
)
}

View File

@ -0,0 +1,307 @@
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"
import { useTranslation } from 'react-i18next'
interface AssistantItemProps {
assistant: Tables<"assistants">
}
export const AssistantItem: FC<AssistantItemProps> = ({ assistant }) => {
const { t } = useTranslation()
const { selectedWorkspace, assistantImages } = useContext(ChatbotUIContext)
const [name, setName] = useState(assistant.name)
const [isTyping, setIsTyping] = useState(false)
const [description, setDescription] = useState(assistant.description)
const [assistantChatSettings, setAssistantChatSettings] = useState({
model: assistant.model,
prompt: assistant.prompt,
temperature: assistant.temperature,
contextLength: assistant.context_length,
includeProfileContext: assistant.include_profile_context,
includeWorkspaceInstructions: assistant.include_workspace_instructions
})
const [selectedImage, setSelectedImage] = useState<File | null>(null)
const [imageLink, setImageLink] = useState("")
useEffect(() => {
const assistantImage =
assistantImages.find(image => image.path === assistant.image_path)
?.base64 || ""
setImageLink(assistantImage)
}, [assistant, assistantImages])
const handleFileSelect = (
file: Tables<"files">,
setSelectedAssistantFiles: React.Dispatch<
React.SetStateAction<Tables<"files">[]>
>
) => {
setSelectedAssistantFiles(prevState => {
const isFileAlreadySelected = prevState.find(
selectedFile => selectedFile.id === file.id
)
if (isFileAlreadySelected) {
return prevState.filter(selectedFile => selectedFile.id !== file.id)
} else {
return [...prevState, file]
}
})
}
const handleCollectionSelect = (
collection: Tables<"collections">,
setSelectedAssistantCollections: React.Dispatch<
React.SetStateAction<Tables<"collections">[]>
>
) => {
setSelectedAssistantCollections(prevState => {
const isCollectionAlreadySelected = prevState.find(
selectedCollection => selectedCollection.id === collection.id
)
if (isCollectionAlreadySelected) {
return prevState.filter(
selectedCollection => selectedCollection.id !== collection.id
)
} else {
return [...prevState, collection]
}
})
}
const handleToolSelect = (
tool: Tables<"tools">,
setSelectedAssistantTools: React.Dispatch<
React.SetStateAction<Tables<"tools">[]>
>
) => {
setSelectedAssistantTools(prevState => {
const isToolAlreadySelected = prevState.find(
selectedTool => selectedTool.id === tool.id
)
if (isToolAlreadySelected) {
return prevState.filter(selectedTool => selectedTool.id !== tool.id)
} else {
return [...prevState, tool]
}
})
}
if (!profile) return null
if (!selectedWorkspace) return null
return (
<SidebarItem
item={assistant}
contentType="assistants"
isTyping={isTyping}
icon={
imageLink ? (
<Image
style={{ width: "30px", height: "30px" }}
className="rounded"
src={imageLink}
alt={assistant.name}
width={30}
height={30}
/>
) : (
<IconRobotFace
className="bg-primary text-secondary border-primary rounded border-DEFAULT p-1"
size={30}
/>
)
}
updateState={{
image: selectedImage,
user_id: assistant.user_id,
name,
description,
include_profile_context: assistantChatSettings.includeProfileContext,
include_workspace_instructions:
assistantChatSettings.includeWorkspaceInstructions,
context_length: assistantChatSettings.contextLength,
model: assistantChatSettings.model,
image_path: assistant.image_path,
prompt: assistantChatSettings.prompt,
temperature: assistantChatSettings.temperature
}}
renderInputs={(renderState: {
startingAssistantFiles: Tables<"files">[]
setStartingAssistantFiles: React.Dispatch<
React.SetStateAction<Tables<"files">[]>
>
selectedAssistantFiles: Tables<"files">[]
setSelectedAssistantFiles: React.Dispatch<
React.SetStateAction<Tables<"files">[]>
>
startingAssistantCollections: Tables<"collections">[]
setStartingAssistantCollections: React.Dispatch<
React.SetStateAction<Tables<"collections">[]>
>
selectedAssistantCollections: Tables<"collections">[]
setSelectedAssistantCollections: React.Dispatch<
React.SetStateAction<Tables<"collections">[]>
>
startingAssistantTools: Tables<"tools">[]
setStartingAssistantTools: React.Dispatch<
React.SetStateAction<Tables<"tools">[]>
>
selectedAssistantTools: Tables<"tools">[]
setSelectedAssistantTools: React.Dispatch<
React.SetStateAction<Tables<"tools">[]>
>
}) => (
<>
<div className="space-y-1">
<Label>{t("side.name")}</Label>
<Input
placeholder={t("side.assistantNamePlaceholder")}
value={name}
onChange={e => setName(e.target.value)}
maxLength={ASSISTANT_NAME_MAX}
/>
</div>
<div className="space-y-1 pt-2">
<Label>{t("side.description")}</Label>
<Input
placeholder={t("side.assistantDescriptionPlaceholder")}
value={description}
onChange={e => setDescription(e.target.value)}
maxLength={ASSISTANT_DESCRIPTION_MAX}
/>
</div>
<div className="space-y-1">
<Label>{t("side.image")}</Label>
<ImagePicker
src={imageLink}
image={selectedImage}
onSrcChange={setImageLink}
onImageChange={setSelectedImage}
width={100}
height={100}
/>
</div>
<ChatSettingsForm
chatSettings={assistantChatSettings as any}
onChangeChatSettings={setAssistantChatSettings}
useAdvancedDropdown={true}
/>
<div className="space-y-1 pt-2">
<Label>{t("side.filesAndCollections")}</Label>
<AssistantRetrievalSelect
selectedAssistantRetrievalItems={
[
...renderState.selectedAssistantFiles,
...renderState.selectedAssistantCollections
].length === 0
? [
...renderState.startingAssistantFiles,
...renderState.startingAssistantCollections
]
: [
...renderState.startingAssistantFiles.filter(
startingFile =>
![
...renderState.selectedAssistantFiles,
...renderState.selectedAssistantCollections
].some(
selectedFile => selectedFile.id === startingFile.id
)
),
...renderState.selectedAssistantFiles.filter(
selectedFile =>
!renderState.startingAssistantFiles.some(
startingFile => startingFile.id === selectedFile.id
)
),
...renderState.startingAssistantCollections.filter(
startingCollection =>
![
...renderState.selectedAssistantFiles,
...renderState.selectedAssistantCollections
].some(
selectedCollection =>
selectedCollection.id === startingCollection.id
)
),
...renderState.selectedAssistantCollections.filter(
selectedCollection =>
!renderState.startingAssistantCollections.some(
startingCollection =>
startingCollection.id === selectedCollection.id
)
)
]
}
onAssistantRetrievalItemsSelect={item =>
"type" in item
? handleFileSelect(
item,
renderState.setSelectedAssistantFiles
)
: handleCollectionSelect(
item,
renderState.setSelectedAssistantCollections
)
}
/>
</div>
<div className="space-y-1">
<Label>{t("side.tools")}</Label>
<AssistantToolSelect
selectedAssistantTools={
renderState.selectedAssistantTools.length === 0
? renderState.startingAssistantTools
: [
...renderState.startingAssistantTools.filter(
startingTool =>
!renderState.selectedAssistantTools.some(
selectedTool => selectedTool.id === startingTool.id
)
),
...renderState.selectedAssistantTools.filter(
selectedTool =>
!renderState.startingAssistantTools.some(
startingTool => startingTool.id === selectedTool.id
)
)
]
}
onAssistantToolsSelect={tool =>
handleToolSelect(tool, renderState.setSelectedAssistantTools)
}
/>
</div>
</>
)}
/>
)
}

View File

@ -0,0 +1,201 @@
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"
import { useTranslation } from 'react-i18next'
interface AssistantRetrievalSelectProps {
selectedAssistantRetrievalItems: Tables<"files">[] | Tables<"collections">[]
onAssistantRetrievalItemsSelect: (
item: Tables<"files"> | Tables<"collections">
) => void
}
export const AssistantRetrievalSelect: FC<AssistantRetrievalSelectProps> = ({
selectedAssistantRetrievalItems,
onAssistantRetrievalItemsSelect
}) => {
const { t } = useTranslation()
const { files, collections } = useContext(ChatbotUIContext)
const inputRef = useRef<HTMLInputElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const [isOpen, setIsOpen] = useState(false)
const [search, setSearch] = useState("")
useEffect(() => {
if (isOpen) {
setTimeout(() => {
inputRef.current?.focus()
}, 100) // FIX: hacky
}
}, [isOpen])
const handleItemSelect = (item: Tables<"files"> | Tables<"collections">) => {
onAssistantRetrievalItemsSelect(item)
}
if (!files || !collections) return null
return (
<DropdownMenu
open={isOpen}
onOpenChange={isOpen => {
setIsOpen(isOpen)
setSearch("")
}}
>
<DropdownMenuTrigger
className="bg-background w-full justify-start border-2 px-3 py-5"
asChild
>
<Button
ref={triggerRef}
className="flex items-center justify-between"
variant="ghost"
>
<div className="flex items-center">
<div className="ml-2 flex items-center">
{selectedAssistantRetrievalItems.length} {t("side.filesSelected")}
</div>
</div>
<IconChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
style={{ width: triggerRef.current?.offsetWidth }}
className="space-y-2 overflow-auto p-2"
align="start"
>
<Input
ref={inputRef}
placeholder={t("side.searchFilesPlaceholder")}
value={search}
onChange={e => setSearch(e.target.value)}
onKeyDown={e => e.stopPropagation()}
/>
{selectedAssistantRetrievalItems
.filter(item =>
item.name.toLowerCase().includes(search.toLowerCase())
)
.map(item => (
<AssistantRetrievalItemOption
key={item.id}
contentType={
item.hasOwnProperty("type") ? "files" : "collections"
}
item={item as Tables<"files"> | Tables<"collections">}
selected={selectedAssistantRetrievalItems.some(
selectedAssistantRetrieval =>
selectedAssistantRetrieval.id === item.id
)}
onSelect={handleItemSelect}
/>
))}
{files
.filter(
file =>
!selectedAssistantRetrievalItems.some(
selectedAssistantRetrieval =>
selectedAssistantRetrieval.id === file.id
) && file.name.toLowerCase().includes(search.toLowerCase())
)
.map(file => (
<AssistantRetrievalItemOption
key={file.id}
item={file}
contentType="files"
selected={selectedAssistantRetrievalItems.some(
selectedAssistantRetrieval =>
selectedAssistantRetrieval.id === file.id
)}
onSelect={handleItemSelect}
/>
))}
{collections
.filter(
collection =>
!selectedAssistantRetrievalItems.some(
selectedAssistantRetrieval =>
selectedAssistantRetrieval.id === collection.id
) && collection.name.toLowerCase().includes(search.toLowerCase())
)
.map(collection => (
<AssistantRetrievalItemOption
key={collection.id}
contentType="collections"
item={collection}
selected={selectedAssistantRetrievalItems.some(
selectedAssistantRetrieval =>
selectedAssistantRetrieval.id === collection.id
)}
onSelect={handleItemSelect}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
interface AssistantRetrievalOptionItemProps {
contentType: "files" | "collections"
item: Tables<"files"> | Tables<"collections">
selected: boolean
onSelect: (item: Tables<"files"> | Tables<"collections">) => void
}
const AssistantRetrievalItemOption: FC<AssistantRetrievalOptionItemProps> = ({
contentType,
item,
selected,
onSelect
}) => {
const handleSelect = () => {
onSelect(item)
}
return (
<div
className="flex cursor-pointer items-center justify-between py-0.5 hover:opacity-50"
onClick={handleSelect}
>
<div className="flex grow items-center truncate">
{contentType === "files" ? (
<div className="mr-2 min-w-[24px]">
<FileIcon type={(item as Tables<"files">).type} size={24} />
</div>
) : (
<div className="mr-2 min-w-[24px]">
<IconBooks size={24} />
</div>
)}
<div className="truncate">{item.name}</div>
</div>
{selected && (
<IconCircleCheckFilled size={20} className="min-w-[30px] flex-none" />
)}
</div>
)
}

View File

@ -0,0 +1,165 @@
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"
import { useTranslation } from 'react-i18next'
interface AssistantToolSelectProps {
selectedAssistantTools: Tables<"tools">[]
onAssistantToolsSelect: (tool: Tables<"tools">) => void
}
export const AssistantToolSelect: FC<AssistantToolSelectProps> = ({
selectedAssistantTools,
onAssistantToolsSelect
}) => {
const { t } = useTranslation()
const { tools } = useContext(ChatbotUIContext)
const inputRef = useRef<HTMLInputElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const [isOpen, setIsOpen] = useState(false)
const [search, setSearch] = useState("")
useEffect(() => {
if (isOpen) {
setTimeout(() => {
inputRef.current?.focus()
}, 100) // FIX: hacky
}
}, [isOpen])
const handleToolSelect = (tool: Tables<"tools">) => {
onAssistantToolsSelect(tool)
}
if (!tools) return null
return (
<DropdownMenu
open={isOpen}
onOpenChange={isOpen => {
setIsOpen(isOpen)
setSearch("")
}}
>
<DropdownMenuTrigger
className="bg-background w-full justify-start border-2 px-3 py-5"
asChild
>
<Button
ref={triggerRef}
className="flex items-center justify-between"
variant="ghost"
>
<div className="flex items-center">
<div className="ml-2 flex items-center">
{selectedAssistantTools.length} {t("side.toolsSelected")}
</div>
</div>
<IconChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
style={{ width: triggerRef.current?.offsetWidth }}
className="space-y-2 overflow-auto p-2"
align="start"
>
<Input
ref={inputRef}
placeholder={t("side.searchToolsPlaceholder")}
value={search}
onChange={e => setSearch(e.target.value)}
onKeyDown={e => e.stopPropagation()}
/>
{selectedAssistantTools
.filter(item =>
item.name.toLowerCase().includes(search.toLowerCase())
)
.map(tool => (
<AssistantToolItem
key={tool.id}
tool={tool}
selected={selectedAssistantTools.some(
selectedAssistantRetrieval =>
selectedAssistantRetrieval.id === tool.id
)}
onSelect={handleToolSelect}
/>
))}
{tools
.filter(
tool =>
!selectedAssistantTools.some(
selectedAssistantRetrieval =>
selectedAssistantRetrieval.id === tool.id
) && tool.name.toLowerCase().includes(search.toLowerCase())
)
.map(tool => (
<AssistantToolItem
key={tool.id}
tool={tool}
selected={selectedAssistantTools.some(
selectedAssistantRetrieval =>
selectedAssistantRetrieval.id === tool.id
)}
onSelect={handleToolSelect}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
interface AssistantToolItemProps {
tool: Tables<"tools">
selected: boolean
onSelect: (tool: Tables<"tools">) => void
}
const AssistantToolItem: FC<AssistantToolItemProps> = ({
tool,
selected,
onSelect
}) => {
const handleSelect = () => {
onSelect(tool)
}
return (
<div
className="flex cursor-pointer items-center justify-between py-0.5 hover:opacity-50"
onClick={handleSelect}
>
<div className="flex grow items-center truncate">
<div className="mr-2 min-w-[24px]">
<IconBolt size={24} />
</div>
<div className="truncate">{tool.name}</div>
</div>
{selected && (
<IconCircleCheckFilled size={20} className="min-w-[30px] flex-none" />
)}
</div>
)
}

View File

@ -0,0 +1,216 @@
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"
import { useTranslation } from 'react-i18next'
interface CreateAssistantProps {
isOpen: boolean
onOpenChange: (isOpen: boolean) => void
}
export const CreateAssistant: FC<CreateAssistantProps> = ({
isOpen,
onOpenChange
}) => {
const { t } = useTranslation()
const { profile, selectedWorkspace } = useContext(ChatbotUIContext)
const [name, setName] = useState("")
const [isTyping, setIsTyping] = useState(false)
const [description, setDescription] = useState("")
const [assistantChatSettings, setAssistantChatSettings] = useState({
model: selectedWorkspace?.default_model,
prompt: selectedWorkspace?.default_prompt,
temperature: selectedWorkspace?.default_temperature,
contextLength: selectedWorkspace?.default_context_length,
includeProfileContext: false,
includeWorkspaceInstructions: false,
embeddingsProvider: selectedWorkspace?.embeddings_provider
})
const [selectedImage, setSelectedImage] = useState<File | null>(null)
const [imageLink, setImageLink] = useState("")
const [selectedAssistantRetrievalItems, setSelectedAssistantRetrievalItems] =
useState<Tables<"files">[] | Tables<"collections">[]>([])
const [selectedAssistantToolItems, setSelectedAssistantToolItems] = useState<
Tables<"tools">[]
>([])
useEffect(() => {
setAssistantChatSettings(prevSettings => {
const previousPrompt = prevSettings.prompt || ""
const previousPromptParts = previousPrompt.split(". ")
previousPromptParts[0] = name ? `You are ${name}` : ""
return {
...prevSettings,
prompt: previousPromptParts.join(". ")
}
})
}, [name])
const handleRetrievalItemSelect = (
item: Tables<"files"> | Tables<"collections">
) => {
setSelectedAssistantRetrievalItems(prevState => {
const isItemAlreadySelected = prevState.find(
selectedItem => selectedItem.id === item.id
)
if (isItemAlreadySelected) {
return prevState.filter(selectedItem => selectedItem.id !== item.id)
} else {
return [...prevState, item]
}
})
}
const handleToolSelect = (item: Tables<"tools">) => {
setSelectedAssistantToolItems(prevState => {
const isItemAlreadySelected = prevState.find(
selectedItem => selectedItem.id === item.id
)
if (isItemAlreadySelected) {
return prevState.filter(selectedItem => selectedItem.id !== item.id)
} else {
return [...prevState, item]
}
})
}
const checkIfModelIsToolCompatible = () => {
if (!assistantChatSettings.model) return false
const compatibleModels = [
"gpt-4-turbo-preview",
"gpt-4-vision-preview",
"gpt-3.5-turbo-1106",
"gpt-4"
]
const isModelCompatible = compatibleModels.includes(
assistantChatSettings.model
)
return isModelCompatible
}
if (!profile) return null
if (!selectedWorkspace) return null
return (
<SidebarCreateItem
contentType="assistants"
createState={
{
image: selectedImage,
user_id: profile.user_id,
name,
description,
include_profile_context: assistantChatSettings.includeProfileContext,
include_workspace_instructions:
assistantChatSettings.includeWorkspaceInstructions,
context_length: assistantChatSettings.contextLength,
model: assistantChatSettings.model,
image_path: "",
prompt: assistantChatSettings.prompt,
temperature: assistantChatSettings.temperature,
embeddings_provider: assistantChatSettings.embeddingsProvider,
files: selectedAssistantRetrievalItems.filter(item =>
item.hasOwnProperty("type")
) as Tables<"files">[],
collections: selectedAssistantRetrievalItems.filter(
item => !item.hasOwnProperty("type")
) as Tables<"collections">[],
tools: selectedAssistantToolItems
} as TablesInsert<"assistants">
}
isOpen={isOpen}
isTyping={isTyping}
renderInputs={() => (
<>
<div className="space-y-1">
<Label>{t("side.name")}</Label>
<Input
placeholder={t("side.assistantNamePlaceholder")}
value={name}
onChange={e => setName(e.target.value)}
maxLength={ASSISTANT_NAME_MAX}
/>
</div>
<div className="space-y-1 pt-2">
<Label>{t("side.description")}</Label>
<Input
placeholder={t("side.assistantDescriptionPlaceholder")}
value={description}
onChange={e => setDescription(e.target.value)}
maxLength={ASSISTANT_DESCRIPTION_MAX}
/>
</div>
<div className="space-y-1 pt-2">
<Label className="flex space-x-1">
<div>{t("side.image")}</div>
<div className="text-xs">{t("side.optional")}</div>
</Label>
<ImagePicker
src={imageLink}
image={selectedImage}
onSrcChange={setImageLink}
onImageChange={setSelectedImage}
width={100}
height={100}
/>
</div>
<ChatSettingsForm
chatSettings={assistantChatSettings as any}
onChangeChatSettings={setAssistantChatSettings}
useAdvancedDropdown={true}
/>
<div className="space-y-1 pt-2">
<Label>{t("side.filesAndCollections")}</Label>
<AssistantRetrievalSelect
selectedAssistantRetrievalItems={selectedAssistantRetrievalItems}
onAssistantRetrievalItemsSelect={handleRetrievalItemSelect}
/>
</div>
{checkIfModelIsToolCompatible() ? (
<div className="space-y-1">
<Label>{t("side.tools")}</Label>
<AssistantToolSelect
selectedAssistantTools={selectedAssistantToolItems}
onAssistantToolsSelect={handleToolSelect}
/>
</div>
) : (
<div className="pt-1 font-semibold">
{t("side.modelIncompatibleWithTools")}
</div>
)}
</>
)}
onOpenChange={onOpenChange}
/>
)
}

View File

@ -0,0 +1,122 @@
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"
import { usePathname } from "next/navigation"
import i18nConfig from "@/i18nConfig"
interface ChatItemProps {
chat: Tables<"chats">
}
export const ChatItem: FC<ChatItemProps> = ({ chat }) => {
const {
selectedWorkspace,
selectedChat,
availableLocalModels,
assistantImages,
availableOpenRouterModels
} = useContext(ChatbotUIContext)
const pathname = usePathname() // 获取当前路径
const pathSegments = pathname.split("/").filter(Boolean)
const locales = i18nConfig.locales
const defaultLocale = i18nConfig.defaultLocale
const segment = pathSegments[0] as (typeof locales)[number]
const pathLocale = locales.includes(segment) ? segment : null
const localePrefix = pathLocale && pathLocale !== defaultLocale ? `/${pathLocale}` : ""
const router = useRouter()
const params = useParams()
const isActive = params.chatid === chat.id || selectedChat?.id === chat.id
const itemRef = useRef<HTMLDivElement>(null)
const handleClick = () => {
if (!selectedWorkspace) return
return router.push(`${localePrefix}/${selectedWorkspace.id}/chat/${chat.id}`)
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter") {
e.stopPropagation()
itemRef.current?.click()
}
}
const MODEL_DATA = [
...LLM_LIST,
...availableLocalModels,
...availableOpenRouterModels
].find(llm => llm.modelId === chat.model) as LLM
const assistantImage = assistantImages.find(
image => image.assistantId === chat.assistant_id
)?.base64
return (
<div
ref={itemRef}
className={cn(
"hover:bg-accent focus:bg-accent group flex w-full cursor-pointer items-center rounded p-2 hover:opacity-50 focus:outline-none",
isActive && "bg-accent"
)}
tabIndex={0}
onKeyDown={handleKeyDown}
onClick={handleClick}
>
{chat.assistant_id ? (
assistantImage ? (
<Image
style={{ width: "30px", height: "30px" }}
className="rounded"
src={assistantImage}
alt="Assistant image"
width={30}
height={30}
/>
) : (
<IconRobotFace
className="bg-primary text-secondary border-primary rounded border-DEFAULT p-1"
size={30}
/>
)
) : (
<WithTooltip
delayDuration={200}
display={<div>{MODEL_DATA?.modelName}</div>}
trigger={
<ModelIcon provider={MODEL_DATA?.provider} height={30} width={30} />
}
/>
)}
<div className="ml-3 flex-1 truncate text-sm font-semibold">
{chat.name}
</div>
<div
onClick={e => {
e.stopPropagation()
e.preventDefault()
}}
className={`ml-2 flex space-x-2 ${!isActive && "w-11 opacity-0 group-hover:opacity-100"}`}
>
<UpdateChat chat={chat} />
<DeleteChat chat={chat} />
</div>
</div>
)
}

View File

@ -0,0 +1,85 @@
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"
import { useTranslation } from 'react-i18next'
interface DeleteChatProps {
chat: Tables<"chats">
}
export const DeleteChat: FC<DeleteChatProps> = ({ chat }) => {
const { t } = useTranslation()
useHotkey("Backspace", () => setShowChatDialog(true))
const { setChats } = useContext(ChatbotUIContext)
const { handleNewChat } = useChatHandler()
const buttonRef = useRef<HTMLButtonElement>(null)
const [showChatDialog, setShowChatDialog] = useState(false)
const handleDeleteChat = async () => {
await deleteChat(chat.id)
setChats(prevState => prevState.filter(c => c.id !== chat.id))
setShowChatDialog(false)
handleNewChat()
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter") {
buttonRef.current?.click()
}
}
return (
<Dialog open={showChatDialog} onOpenChange={setShowChatDialog}>
<DialogTrigger asChild>
<IconTrash className="hover:opacity-50" size={18} />
</DialogTrigger>
<DialogContent onKeyDown={handleKeyDown}>
<DialogHeader>
<DialogTitle>{t("side.deleteChatTitle")} {chat.name}</DialogTitle>
<DialogDescription>
{t("side.deleteChatConfirm")}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setShowChatDialog(false)}>
{t("side.cancel")}
</Button>
<Button
ref={buttonRef}
variant="destructive"
onClick={handleDeleteChat}
>
{t("side.delete")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,81 @@
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"
import { useTranslation } from 'react-i18next'
interface UpdateChatProps {
chat: Tables<"chats">
}
export const UpdateChat: FC<UpdateChatProps> = ({ chat }) => {
const { t } = useTranslation()
const { setChats } = useContext(ChatbotUIContext)
const buttonRef = useRef<HTMLButtonElement>(null)
const [showChatDialog, setShowChatDialog] = useState(false)
const [name, setName] = useState(chat.name)
const handleUpdateChat = async (e: React.MouseEvent<HTMLButtonElement>) => {
const updatedChat = await updateChat(chat.id, {
name
})
setChats(prevState =>
prevState.map(c => (c.id === chat.id ? updatedChat : c))
)
setShowChatDialog(false)
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter") {
buttonRef.current?.click()
}
}
return (
<Dialog open={showChatDialog} onOpenChange={setShowChatDialog}>
<DialogTrigger asChild>
<IconEdit className="hover:opacity-50" size={18} />
</DialogTrigger>
<DialogContent onKeyDown={handleKeyDown}>
<DialogHeader>
<DialogTitle>{t("side.editChat")}</DialogTitle>
</DialogHeader>
<div className="space-y-1">
<Label>{t("side.name")}</Label>
<Input value={name} onChange={e => setName(e.target.value)} />
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setShowChatDialog(false)}>
{t("side.cancel")}
</Button>
<Button ref={buttonRef} onClick={handleUpdateChat}>
{t("side.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,159 @@
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"
import { useTranslation } from 'react-i18next'
interface CollectionFileSelectProps {
selectedCollectionFiles: CollectionFile[]
onCollectionFileSelect: (file: CollectionFile) => void
}
export const CollectionFileSelect: FC<CollectionFileSelectProps> = ({
selectedCollectionFiles,
onCollectionFileSelect
}) => {
const { t } = useTranslation()
const { files } = useContext(ChatbotUIContext)
const inputRef = useRef<HTMLInputElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const [isOpen, setIsOpen] = useState(false)
const [search, setSearch] = useState("")
useEffect(() => {
if (isOpen) {
setTimeout(() => {
inputRef.current?.focus()
}, 100) // FIX: hacky
}
}, [isOpen])
const handleFileSelect = (file: CollectionFile) => {
onCollectionFileSelect(file)
}
if (!files) return null
return (
<DropdownMenu
open={isOpen}
onOpenChange={isOpen => {
setIsOpen(isOpen)
setSearch("")
}}
>
<DropdownMenuTrigger
className="bg-background w-full justify-start border-2 px-3 py-5"
asChild
>
<Button
ref={triggerRef}
className="flex items-center justify-between"
variant="ghost"
>
<div className="flex items-center">
<div className="ml-2 flex items-center">
{selectedCollectionFiles.length} {t("side.filesSelected")}
</div>
</div>
<IconChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
style={{ width: triggerRef.current?.offsetWidth }}
className="space-y-2 overflow-auto p-2"
align="start"
>
<Input
ref={inputRef}
placeholder={t("side.searchFilesPlaceholder")}
value={search}
onChange={e => setSearch(e.target.value)}
onKeyDown={e => e.stopPropagation()}
/>
{selectedCollectionFiles
.filter(file =>
file.name.toLowerCase().includes(search.toLowerCase())
)
.map(file => (
<CollectionFileItem
key={file.id}
file={file}
selected={selectedCollectionFiles.some(
selectedCollectionFile => selectedCollectionFile.id === file.id
)}
onSelect={handleFileSelect}
/>
))}
{files
.filter(
file =>
!selectedCollectionFiles.some(
selectedCollectionFile => selectedCollectionFile.id === file.id
) && file.name.toLowerCase().includes(search.toLowerCase())
)
.map(file => (
<CollectionFileItem
key={file.id}
file={file}
selected={selectedCollectionFiles.some(
selectedCollectionFile => selectedCollectionFile.id === file.id
)}
onSelect={handleFileSelect}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
interface CollectionFileItemProps {
file: CollectionFile
selected: boolean
onSelect: (file: CollectionFile) => void
}
const CollectionFileItem: FC<CollectionFileItemProps> = ({
file,
selected,
onSelect
}) => {
const handleSelect = () => {
onSelect(file)
}
return (
<div
className="flex cursor-pointer items-center justify-between py-0.5 hover:opacity-50"
onClick={handleSelect}
>
<div className="flex grow items-center truncate">
<div className="mr-2 min-w-[24px]">
<FileIcon type={file.type} size={24} />
</div>
<div className="truncate">{file.name}</div>
</div>
{selected && (
<IconCircleCheckFilled size={20} className="min-w-[30px] flex-none" />
)}
</div>
)
}

View File

@ -0,0 +1,122 @@
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"
import { useTranslation } from 'react-i18next'
interface CollectionItemProps {
collection: Tables<"collections">
}
export const CollectionItem: FC<CollectionItemProps> = ({ collection }) => {
const { t } = useTranslation()
const [name, setName] = useState(collection.name)
const [isTyping, setIsTyping] = useState(false)
const [description, setDescription] = useState(collection.description)
const handleFileSelect = (
file: CollectionFile,
setSelectedCollectionFiles: React.Dispatch<
React.SetStateAction<CollectionFile[]>
>
) => {
setSelectedCollectionFiles(prevState => {
const isFileAlreadySelected = prevState.find(
selectedFile => selectedFile.id === file.id
)
if (isFileAlreadySelected) {
return prevState.filter(selectedFile => selectedFile.id !== file.id)
} else {
return [...prevState, file]
}
})
}
return (
<SidebarItem
item={collection}
isTyping={isTyping}
contentType="collections"
icon={<IconBooks size={30} />}
updateState={{
name,
description
}}
renderInputs={(renderState: {
startingCollectionFiles: CollectionFile[]
setStartingCollectionFiles: React.Dispatch<
React.SetStateAction<CollectionFile[]>
>
selectedCollectionFiles: CollectionFile[]
setSelectedCollectionFiles: React.Dispatch<
React.SetStateAction<CollectionFile[]>
>
}) => {
return (
<>
<div className="space-y-1">
<Label>{t("side.files")}</Label>
<CollectionFileSelect
selectedCollectionFiles={
renderState.selectedCollectionFiles.length === 0
? renderState.startingCollectionFiles
: [
...renderState.startingCollectionFiles.filter(
startingFile =>
!renderState.selectedCollectionFiles.some(
selectedFile =>
selectedFile.id === startingFile.id
)
),
...renderState.selectedCollectionFiles.filter(
selectedFile =>
!renderState.startingCollectionFiles.some(
startingFile =>
startingFile.id === selectedFile.id
)
)
]
}
onCollectionFileSelect={file =>
handleFileSelect(file, renderState.setSelectedCollectionFiles)
}
/>
</div>
<div className="space-y-1">
<Label>{t("side.name")}</Label>
<Input
placeholder={t("side.collectionNamePlaceholder")}
value={name}
onChange={e => setName(e.target.value)}
maxLength={COLLECTION_NAME_MAX}
/>
</div>
<div className="space-y-1">
<Label>{t("side.description")}</Label>
<Input
placeholder={t("side.collectionDescriptionPlaceholder")}
value={description}
onChange={e => setDescription(e.target.value)}
maxLength={COLLECTION_DESCRIPTION_MAX}
/>
</div>
</>
)
}}
/>
)
}

View File

@ -0,0 +1,104 @@
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"
import { useTranslation } from 'react-i18next'
interface CreateCollectionProps {
isOpen: boolean
onOpenChange: (isOpen: boolean) => void
}
export const CreateCollection: FC<CreateCollectionProps> = ({
isOpen,
onOpenChange
}) => {
const { t } = useTranslation()
const { profile, selectedWorkspace } = useContext(ChatbotUIContext)
const [name, setName] = useState("")
const [isTyping, setIsTyping] = useState(false)
const [description, setDescription] = useState("")
const [selectedCollectionFiles, setSelectedCollectionFiles] = useState<
CollectionFile[]
>([])
const handleFileSelect = (file: CollectionFile) => {
setSelectedCollectionFiles(prevState => {
const isFileAlreadySelected = prevState.find(
selectedFile => selectedFile.id === file.id
)
if (isFileAlreadySelected) {
return prevState.filter(selectedFile => selectedFile.id !== file.id)
} else {
return [...prevState, file]
}
})
}
if (!profile) return null
if (!selectedWorkspace) return null
return (
<SidebarCreateItem
contentType="collections"
createState={
{
collectionFiles: selectedCollectionFiles.map(file => ({
user_id: profile.user_id,
collection_id: "",
file_id: file.id
})),
user_id: profile.user_id,
name,
description
} as TablesInsert<"collections">
}
isOpen={isOpen}
isTyping={isTyping}
onOpenChange={onOpenChange}
renderInputs={() => (
<>
<div className="space-y-1">
<Label>{t("side.files")}</Label>
<CollectionFileSelect
selectedCollectionFiles={selectedCollectionFiles}
onCollectionFileSelect={handleFileSelect}
/>
</div>
<div className="space-y-1">
<Label>{t("side.name")}</Label>
<Input
placeholder="Collection name..."
value={name}
onChange={e => setName(e.target.value)}
maxLength={COLLECTION_NAME_MAX}
/>
</div>
<div className="space-y-1">
<Label>{t("side.description")}</Label>
<Input
placeholder={t("side.collectionDescriptionPlaceholder")}
value={description}
onChange={e => setDescription(e.target.value)}
maxLength={COLLECTION_DESCRIPTION_MAX}
/>
</div>
</>
)}
/>
)
}

View File

@ -0,0 +1,97 @@
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"
import { useTranslation } from 'react-i18next'
interface CreateFileProps {
isOpen: boolean
onOpenChange: (isOpen: boolean) => void
}
export const CreateFile: FC<CreateFileProps> = ({ isOpen, onOpenChange }) => {
const { t } = useTranslation()
const { profile, selectedWorkspace } = useContext(ChatbotUIContext)
const [name, setName] = useState("")
const [isTyping, setIsTyping] = useState(false)
const [description, setDescription] = useState("")
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const handleSelectedFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files) return
const file = e.target.files[0]
if (!file) return
setSelectedFile(file)
const fileNameWithoutExtension = file.name.split(".").slice(0, -1).join(".")
setName(fileNameWithoutExtension)
}
if (!profile) return null
if (!selectedWorkspace) return null
return (
<SidebarCreateItem
contentType="files"
createState={
{
file: selectedFile,
user_id: profile.user_id,
name,
description,
file_path: "",
size: selectedFile?.size || 0,
tokens: 0,
type: selectedFile?.type || 0
} as TablesInsert<"files">
}
isOpen={isOpen}
isTyping={isTyping}
onOpenChange={onOpenChange}
renderInputs={() => (
<>
<div className="space-y-1">
<Label>{t("side.file")}</Label>
<Input
type="file"
onChange={handleSelectedFile}
accept={ACCEPTED_FILE_TYPES}
/>
</div>
<div className="space-y-1">
<Label>{t("side.name")}</Label>
<Input
placeholder={t("side.fileNamePlaceholder")}
value={name}
onChange={e => setName(e.target.value)}
maxLength={FILE_NAME_MAX}
/>
</div>
<div className="space-y-1">
<Label>{t("side.description")}</Label>
<Input
placeholder={t("side.fileDescriptionPlaceholder")}
value={name}
onChange={e => setDescription(e.target.value)}
maxLength={FILE_DESCRIPTION_MAX}
/>
</div>
</>
)}
/>
)
}

View File

@ -0,0 +1,97 @@
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"
import { useTranslation } from 'react-i18next'
interface FileItemProps {
file: Tables<"files">
}
export const FileItem: FC<FileItemProps> = ({ file }) => {
const { t } = useTranslation()
const [name, setName] = useState(file.name)
const [isTyping, setIsTyping] = useState(false)
const [description, setDescription] = useState(file.description)
const getLinkAndView = async () => {
const link = await getFileFromStorage(file.file_path)
window.open(link, "_blank")
}
return (
<SidebarItem
item={file}
isTyping={isTyping}
contentType="files"
icon={<FileIcon type={file.type} size={30} />}
updateState={{ name, description }}
renderInputs={() => (
<>
<div
className="cursor-pointer underline hover:opacity-50"
onClick={getLinkAndView}
>
{t("side.view")} {file.name}
</div>
<div className="flex flex-col justify-between">
<div>{file.type}</div>
<div>{formatFileSize(file.size)}</div>
<div>{file.tokens.toLocaleString()} tokens</div>
</div>
<div className="space-y-1">
<Label>{t("side.name")}</Label>
<Input
placeholder={t("side.fileNamePlaceholder")}
value={name}
onChange={e => setName(e.target.value)}
maxLength={FILE_NAME_MAX}
/>
</div>
<div className="space-y-1">
<Label>{t("side.description")}</Label>
<Input
placeholder={t("side.fileDescriptionPlaceholder")}
value={description}
onChange={e => setDescription(e.target.value)}
maxLength={FILE_DESCRIPTION_MAX}
/>
</div>
</>
)}
/>
)
}
export const formatFileSize = (sizeInBytes: number): string => {
let size = sizeInBytes
let unit = "bytes"
if (size >= 1024) {
size /= 1024
unit = "KB"
}
if (size >= 1024) {
size /= 1024
unit = "MB"
}
if (size >= 1024) {
size /= 1024
unit = "GB"
}
return `${size.toFixed(2)} ${unit}`
}

Some files were not shown because too many files have changed in this diff Show More