Compare commits
148 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bdbf1cbe3 | |||
| 4fd5210df6 | |||
| e1f286efb8 | |||
| d570c8b1ed | |||
| e8150e7195 | |||
| 6156a2702f | |||
| 165472f121 | |||
| 83e25b97b0 | |||
| b7732a95a6 | |||
| b3b65f8ce5 | |||
| 2c236e2c07 | |||
| bc2435d4cb | |||
| a89308d03a | |||
| 90c0e7d35d | |||
| e8e74bf773 | |||
| 1dc4f86df5 | |||
| ae18abe931 | |||
| f9ddf07085 | |||
| 91cbe0b104 | |||
| b7b6bbaaca | |||
| 23ad285a4b | |||
| 56d3b2fba2 | |||
| d68f77867d | |||
| 462ca9bb04 | |||
| 8f3a3ae3e7 | |||
| cfb610df1e | |||
| 9683ce21b3 | |||
| 3650d8d7bf | |||
| aefec17525 | |||
| 863d8d8121 | |||
| c1b7b0e070 | |||
| 22bcaef805 | |||
| 317bc9b6f3 | |||
| 40828f1568 | |||
| 31129919bf | |||
| 2114b7296e | |||
| 5cdd3e56b7 | |||
| ef8c1b2c33 | |||
| 3631a5ac75 | |||
| 4d7adf477a | |||
| e020de47ce | |||
| 85c4f88a06 | |||
| 6ef83b0cb6 | |||
| 00c6c72270 | |||
| 3f82710cdd | |||
| b2ef40cf6f | |||
| 52d47292ad | |||
| aefeac2902 | |||
| baca00c59e | |||
| a70ae8799d | |||
| cd49445491 | |||
| abdcd4508d | |||
| 3749c9b2af | |||
| 11d6172e95 | |||
| 1f9d17f8bf | |||
| a73ef2b8cf | |||
| 28c8bf0e0d | |||
| a78a8c4a94 | |||
| 5d31947ab9 | |||
| 7c74df338e | |||
| 00d807495d | |||
| a3eb247c3f | |||
| b0c289f7a4 | |||
| 5aa5be3f43 | |||
| 34c79c0d66 | |||
| 2269403806 | |||
| a1743c82cc | |||
| 3ca503a3f2 | |||
| d8e3844fb9 | |||
| 82401a4142 | |||
| 90399d24cc | |||
| 46e1857489 | |||
| c3f2dced56 | |||
| df7c363ccb | |||
| bf8830e1a5 | |||
| 0e393b0bec | |||
| 6d5d09d69f | |||
| 5d9bc10cf4 | |||
| 0bd7d86174 | |||
| b0059fdf0d | |||
| 831245c837 | |||
| c0b1b2eadb | |||
| ff13a3eab8 | |||
| 4d0d1e8b95 | |||
| 0f07812cc5 | |||
| 675da9431d | |||
| 10354fb290 | |||
| d6973b9ccc | |||
| b843f6e0e0 | |||
| 9706f67bb4 | |||
| 71d7e44bce | |||
| 499007da94 | |||
| 14fe29c03a | |||
| fffb729b34 | |||
| e18276223b | |||
| 3915cef98a | |||
| d281c161a2 | |||
| a0a2cb8b35 | |||
| f698d9f3c4 | |||
| 0038bb8366 | |||
| 1253565a69 | |||
| 9a6ad3d66c | |||
| 966021ed74 | |||
| dd44a06213 | |||
| 2ae4c69de7 | |||
| f5ebde2d2d | |||
| cb58a703e3 | |||
| b89ca2082e | |||
| 3f09a4c355 | |||
| 92eab6c634 | |||
| 932853f1ba | |||
| 4fbb5e1f79 | |||
| 698c3bda29 | |||
| 93a8e814d6 | |||
| d27326125b | |||
| d7fdcd0dfe | |||
| a005323964 | |||
| 801451993c | |||
| 2c8e8547d0 | |||
| 1e6531354d | |||
| 291e2d9852 | |||
| 69e05160a3 | |||
| 338ddedfec | |||
| a03d8b2ba9 | |||
| e30336c00e | |||
| c3132ef2fb | |||
| 4876dced04 | |||
| c73f604819 | |||
| bc3b6d3355 | |||
| ad2e1f0d4c | |||
| 9d88722f35 | |||
| 93b528f10d | |||
| 1f31cc5507 | |||
| f5118e3037 | |||
| 1a4b4401ee | |||
| 7ce2d5ec2c | |||
| 42c48f290f | |||
| 83217c6d83 | |||
| 2b1ef7be3e | |||
| 1789351ab5 | |||
| 71a770c24e | |||
| 68cd41a6dc | |||
| 83987d3021 | |||
| da11d0b91e | |||
| a1a8ac42a6 | |||
| d30471f5a9 | |||
| 52004c938b | |||
| 172bb8e5b1 |
+1
-1
@@ -1,4 +1,4 @@
|
||||
.env
|
||||
.env.local
|
||||
node_modules
|
||||
|
||||
test-results
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Chatbot UI
|
||||
DEFAULT_MODEL=gpt-3.5-turbo
|
||||
DEFAULT_SYSTEM_PROMPT=You are ChatGPT, a large language model trained by OpenAI. Follow the user's instructions carefully. Respond using markdown.
|
||||
OPENAI_API_KEY=YOUR_KEY
|
||||
|
||||
# Google
|
||||
GOOGLE_API_KEY=YOUR_API_KEY
|
||||
GOOGLE_CSE_ID=YOUR_ENGINE_ID
|
||||
@@ -0,0 +1,67 @@
|
||||
name: Docker
|
||||
|
||||
# This workflow uses actions that are not certified by GitHub.
|
||||
# They are provided by a third-party and are governed by
|
||||
# separate terms of service, privacy policy, and support
|
||||
# documentation.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
|
||||
env:
|
||||
# Use docker.io for Docker Hub if empty
|
||||
REGISTRY: ghcr.io
|
||||
# github.repository as <account>/<repo>
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
# This is used to complete the identity challenge
|
||||
# with sigstore/fulcio when running outside of PRs.
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Workaround: https://github.com/docker/build-push-action/issues/461
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@79abd3f86f79a9d68a23c75a09a9a85889262adf
|
||||
|
||||
# Login against a Docker registry except on PR
|
||||
# https://github.com/docker/login-action
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Extract metadata (tags, labels) for Docker
|
||||
# https://github.com/docker/metadata-action
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
# Build and push Docker image with Buildx (don't push on PR)
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@ac9327eae2b366085ac7f6a2d02df8aa8ead720a
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Run Unit Tests
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: node:16
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run Vitest Suite
|
||||
run: npm test
|
||||
+3
-2
@@ -7,11 +7,12 @@
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
/test-results
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
dist
|
||||
/dist
|
||||
|
||||
# production
|
||||
/build
|
||||
@@ -35,4 +36,4 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.idea
|
||||
.idea
|
||||
@@ -0,0 +1,41 @@
|
||||
# Contributing Guidelines
|
||||
**Welcome to Chatbot UI!**
|
||||
|
||||
We appreciate your interest in contributing to our project.
|
||||
|
||||
Before you get started, please read our guidelines for contributing.
|
||||
|
||||
## Types of Contributions
|
||||
We welcome the following types of contributions:
|
||||
|
||||
- Bug fixes
|
||||
- New features
|
||||
- Documentation improvements
|
||||
- Code optimizations
|
||||
- Translations
|
||||
- Tests
|
||||
|
||||
|
||||
## Getting Started
|
||||
To get started, fork the project on GitHub and clone it locally on your machine. Then, create a new branch to work on your changes.
|
||||
|
||||
```
|
||||
git clone https://github.com/mckaywrigley/chatbot-ui.git
|
||||
cd chatbot-ui
|
||||
git checkout -b my-branch-name
|
||||
|
||||
```
|
||||
|
||||
Before submitting your pull request, please make sure your changes pass our automated tests and adhere to our code style guidelines.
|
||||
|
||||
## Pull Request Process
|
||||
1. Fork the project on GitHub.
|
||||
2. Clone your forked repository locally on your machine.
|
||||
3. Create a new branch from the main branch.
|
||||
4. Make your changes on the new branch.
|
||||
5. Ensure that your changes adhere to our code style guidelines and pass our automated tests.
|
||||
6. Commit your changes and push them to your forked repository.
|
||||
7. Submit a pull request to the main branch of the main repository.
|
||||
|
||||
## Contact
|
||||
If you have any questions or need help getting started, feel free to reach out to me on [Twitter](https://twitter.com/mckaywrigley).
|
||||
@@ -19,6 +19,8 @@ COPY --from=dependencies /app/node_modules ./node_modules
|
||||
COPY --from=build /app/.next ./.next
|
||||
COPY --from=build /app/public ./public
|
||||
COPY --from=build /app/package*.json ./
|
||||
COPY --from=build /app/next.config.js ./next.config.js
|
||||
COPY --from=build /app/next-i18next.config.js ./next-i18next.config.js
|
||||
|
||||
# Expose the port the app will run on
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
include .env
|
||||
|
||||
.PHONY: all
|
||||
|
||||
build:
|
||||
docker build -t chatbot-ui .
|
||||
|
||||
run:
|
||||
export $(cat .env | xargs)
|
||||
docker stop chatbot-ui || true && docker rm chatbot-ui || true
|
||||
docker run --name chatbot-ui --rm -e OPENAI_API_KEY=${OPENAI_API_KEY} -p 3000:3000 chatbot-ui
|
||||
|
||||
logs:
|
||||
docker logs -f chatbot-ui
|
||||
|
||||
push:
|
||||
docker tag chatbot-ui:latest ${DOCKER_USER}/chatbot-ui:${DOCKER_TAG}
|
||||
docker push ${DOCKER_USER}/chatbot-ui:${DOCKER_TAG}
|
||||
@@ -1,14 +1,8 @@
|
||||
# Chatbot UI
|
||||
|
||||
**Note: Chatbot UI Pro has been renamed to Chatbot UI.**
|
||||
|
||||
Chatbot UI is an advanced chatbot kit for OpenAI's chat models built on top of [Chatbot UI Lite](https://github.com/mckaywrigley/chatbot-ui-lite) using Next.js, TypeScript, and Tailwind CSS.
|
||||
|
||||
It aims to mimic ChatGPT's interface and functionality.
|
||||
|
||||
All conversations are stored locally on your device.
|
||||
|
||||
See a [demo](https://twitter.com/mckaywrigley/status/1636103188733640704).
|
||||
See a [demo](https://twitter.com/mckaywrigley/status/1640380021423603713?s=46&t=AowqkodyK6B4JccSOxSPew).
|
||||
|
||||

|
||||
|
||||
@@ -20,14 +14,18 @@ Expect frequent improvements.
|
||||
|
||||
**Next up:**
|
||||
|
||||
- [ ] More custom model settings
|
||||
- [ ] Regenerate & edit responses
|
||||
- [ ] Saving via data export
|
||||
- [ ] Folders
|
||||
- [ ] Prompt templates
|
||||
- [ ] Delete messages
|
||||
- [ ] More model settings
|
||||
- [ ] Plugins
|
||||
|
||||
**Recent updates:**
|
||||
|
||||
- [x] Prompt templates (3/27/23)
|
||||
- [x] Regenerate & edit responses (3/25/23)
|
||||
- [x] Folders (3/24/23)
|
||||
- [x] Search chat content (3/23/23)
|
||||
- [x] Stop message generation (3/22/23)
|
||||
- [x] Import/Export chats (3/22/23)
|
||||
- [x] Custom system prompt (3/21/23)
|
||||
- [x] Error handling (3/20/23)
|
||||
- [x] GPT-4 support (access required) (3/20/23)
|
||||
@@ -61,15 +59,17 @@ Fork Chatbot UI on Replit [here](https://replit.com/@MckayWrigley/chatbot-ui-pro
|
||||
|
||||
**Docker**
|
||||
|
||||
Build locally:
|
||||
|
||||
```shell
|
||||
docker build -t chatgpt-ui .
|
||||
docker run -e OPENAI_API_KEY=xxxxxxxx -p 3000:3000 chatgpt-ui
|
||||
```
|
||||
|
||||
**Desktop App**
|
||||
Pull from ghcr:
|
||||
|
||||
```
|
||||
npm run tauri build
|
||||
docker run -e OPENAI_API_KEY=xxxxxxxx -p 3000:3000 ghcr.io/mckaywrigley/chatbot-ui:main
|
||||
```
|
||||
|
||||
## Running Locally
|
||||
@@ -94,6 +94,10 @@ Create a .env.local file in the root of the repo with your OpenAI API Key:
|
||||
OPENAI_API_KEY=YOUR_KEY
|
||||
```
|
||||
|
||||
> You can set `OPENAI_API_HOST` where access to the official OpenAI host is restricted or unavailable, allowing users to configure an alternative host for their specific needs.
|
||||
|
||||
> Additionally, if you have multiple OpenAI Organizations, you can set `OPENAI_ORGANIZATION` to specify one.
|
||||
|
||||
**4. Run App**
|
||||
|
||||
```bash
|
||||
@@ -104,6 +108,19 @@ npm run dev
|
||||
|
||||
You should be able to start chatting.
|
||||
|
||||
## Configuration
|
||||
|
||||
When deploying the application, the following environment variables can be set:
|
||||
|
||||
| Environment Variable | Default value | Description |
|
||||
| --------------------- | ------------------------------ | ------------------------------------------------------- |
|
||||
| OPENAI_API_KEY | | The default API key used for authentication with OpenAI |
|
||||
| DEFAULT_MODEL | `gpt-3.5-turbo` | The default model to use on new conversations |
|
||||
| DEFAULT_SYSTEM_PROMPT | [see here](utils/app/const.ts) | The defaut system prompt to use on new conversations |
|
||||
|
||||
If you do not provide an OpenAI API key with `OPENAI_API_KEY`, users will have to provide their own key.
|
||||
If you don't have an OpenAI API key, you can get one [here](https://platform.openai.com/account/api-keys).
|
||||
|
||||
## Contact
|
||||
|
||||
If you have any questions, feel free to reach out to me on [Twitter](https://twitter.com/mckaywrigley).
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { ExportFormatV1, ExportFormatV2, ExportFormatV4 } from '@/types/export';
|
||||
import { OpenAIModels, OpenAIModelID } from '@/types/openai';
|
||||
import { DEFAULT_SYSTEM_PROMPT } from '@/utils/app/const';
|
||||
import { it, describe, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
cleanData,
|
||||
isExportFormatV1,
|
||||
isExportFormatV2,
|
||||
isExportFormatV3,
|
||||
isExportFormatV4,
|
||||
isLatestExportFormat,
|
||||
} from '@/utils/app/importExport';
|
||||
|
||||
describe('Export Format Functions', () => {
|
||||
describe('isExportFormatV1', () => {
|
||||
it('should return true for v1 format', () => {
|
||||
const obj = [{ id: 1 }];
|
||||
expect(isExportFormatV1(obj)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-v1 formats', () => {
|
||||
const obj = { version: 3, history: [], folders: [] };
|
||||
expect(isExportFormatV1(obj)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExportFormatV2', () => {
|
||||
it('should return true for v2 format', () => {
|
||||
const obj = { history: [], folders: [] };
|
||||
expect(isExportFormatV2(obj)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-v2 formats', () => {
|
||||
const obj = { version: 3, history: [], folders: [] };
|
||||
expect(isExportFormatV2(obj)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExportFormatV3', () => {
|
||||
it('should return true for v3 format', () => {
|
||||
const obj = { version: 3, history: [], folders: [] };
|
||||
expect(isExportFormatV3(obj)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-v3 formats', () => {
|
||||
const obj = { version: 4, history: [], folders: [] };
|
||||
expect(isExportFormatV3(obj)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExportFormatV4', () => {
|
||||
it('should return true for v4 format', () => {
|
||||
const obj = { version: 4, history: [], folders: [], prompts: [] };
|
||||
expect(isExportFormatV4(obj)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-v4 formats', () => {
|
||||
const obj = { version: 5, history: [], folders: [], prompts: [] };
|
||||
expect(isExportFormatV4(obj)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanData Functions', () => {
|
||||
describe('cleaning v1 data', () => {
|
||||
it('should return the latest format', () => {
|
||||
const data = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'conversation 1',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: "what's up ?",
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Hi',
|
||||
},
|
||||
],
|
||||
},
|
||||
] as ExportFormatV1;
|
||||
const obj = cleanData(data);
|
||||
expect(isLatestExportFormat(obj)).toBe(true);
|
||||
expect(obj).toEqual({
|
||||
version: 4,
|
||||
history: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'conversation 1',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: "what's up ?",
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Hi',
|
||||
},
|
||||
],
|
||||
model: OpenAIModels[OpenAIModelID.GPT_3_5],
|
||||
prompt: DEFAULT_SYSTEM_PROMPT,
|
||||
folderId: null,
|
||||
},
|
||||
],
|
||||
folders: [],
|
||||
prompts:[]
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleaning v2 data', () => {
|
||||
it('should return the latest format', () => {
|
||||
const data = {
|
||||
history: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'conversation 1',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: "what's up ?",
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Hi',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
folders: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'folder 1',
|
||||
},
|
||||
],
|
||||
} as ExportFormatV2;
|
||||
const obj = cleanData(data);
|
||||
expect(isLatestExportFormat(obj)).toBe(true);
|
||||
expect(obj).toEqual({
|
||||
version: 4,
|
||||
history: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'conversation 1',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: "what's up ?",
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Hi',
|
||||
},
|
||||
],
|
||||
model: OpenAIModels[OpenAIModelID.GPT_3_5],
|
||||
prompt: DEFAULT_SYSTEM_PROMPT,
|
||||
folderId: null,
|
||||
},
|
||||
],
|
||||
folders: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'folder 1',
|
||||
type: 'chat',
|
||||
},
|
||||
],
|
||||
prompts: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleaning v4 data', () => {
|
||||
it('should return the latest format', () => {
|
||||
const data = {
|
||||
version: 4,
|
||||
history: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'conversation 1',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: "what's up ?",
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Hi',
|
||||
},
|
||||
],
|
||||
model: OpenAIModels[OpenAIModelID.GPT_3_5],
|
||||
prompt: DEFAULT_SYSTEM_PROMPT,
|
||||
folderId: null,
|
||||
},
|
||||
],
|
||||
folders: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'folder 1',
|
||||
type: 'chat',
|
||||
},
|
||||
],
|
||||
prompts: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'prompt 1',
|
||||
description: '',
|
||||
content: '',
|
||||
model: OpenAIModels[OpenAIModelID.GPT_3_5],
|
||||
folderId: null,
|
||||
},
|
||||
],
|
||||
} as ExportFormatV4;
|
||||
|
||||
const obj = cleanData(data);
|
||||
expect(isLatestExportFormat(obj)).toBe(true);
|
||||
expect(obj).toEqual({
|
||||
version: 4,
|
||||
history: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'conversation 1',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: "what's up ?",
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Hi',
|
||||
},
|
||||
],
|
||||
model: OpenAIModels[OpenAIModelID.GPT_3_5],
|
||||
prompt: DEFAULT_SYSTEM_PROMPT,
|
||||
folderId: null,
|
||||
},
|
||||
],
|
||||
folders: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'folder 1',
|
||||
type: 'chat',
|
||||
},
|
||||
],
|
||||
prompts: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'prompt 1',
|
||||
description: '',
|
||||
content: '',
|
||||
model: OpenAIModels[OpenAIModelID.GPT_3_5],
|
||||
folderId: null,
|
||||
},
|
||||
],
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
+309
-88
@@ -1,113 +1,334 @@
|
||||
import { Conversation, KeyValuePair, Message, OpenAIModel } from "@/types";
|
||||
import { FC, MutableRefObject, useEffect, useRef, useState } from "react";
|
||||
import { ChatInput } from "./ChatInput";
|
||||
import { ChatLoader } from "./ChatLoader";
|
||||
import { ChatMessage } from "./ChatMessage";
|
||||
import { ModelSelect } from "./ModelSelect";
|
||||
import { Regenerate } from "./Regenerate";
|
||||
import { SystemPrompt } from "./SystemPrompt";
|
||||
import { Conversation, Message } from '@/types/chat';
|
||||
import { KeyValuePair } from '@/types/data';
|
||||
import { ErrorMessage } from '@/types/error';
|
||||
import { OpenAIModel, OpenAIModelID } from '@/types/openai';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { Prompt } from '@/types/prompt';
|
||||
import { throttle } from '@/utils';
|
||||
import { IconArrowDown, IconClearAll, IconSettings } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import {
|
||||
FC,
|
||||
MutableRefObject,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Spinner } from '../Global/Spinner';
|
||||
import { ChatInput } from './ChatInput';
|
||||
import { ChatLoader } from './ChatLoader';
|
||||
import { ChatMessage } from './ChatMessage';
|
||||
import { ErrorMessageDiv } from './ErrorMessageDiv';
|
||||
import { ModelSelect } from './ModelSelect';
|
||||
import { SystemPrompt } from './SystemPrompt';
|
||||
|
||||
interface Props {
|
||||
conversation: Conversation;
|
||||
models: OpenAIModel[];
|
||||
apiKey: string;
|
||||
serverSideApiKeyIsSet: boolean;
|
||||
defaultModelId: OpenAIModelID;
|
||||
messageIsStreaming: boolean;
|
||||
modelError: boolean;
|
||||
messageError: boolean;
|
||||
modelError: ErrorMessage | null;
|
||||
loading: boolean;
|
||||
lightMode: "light" | "dark";
|
||||
onSend: (message: Message, isResend: boolean) => void;
|
||||
onUpdateConversation: (conversation: Conversation, data: KeyValuePair) => void;
|
||||
prompts: Prompt[];
|
||||
onSend: (
|
||||
message: Message,
|
||||
deleteCount: number,
|
||||
plugin: Plugin | null,
|
||||
) => void;
|
||||
onUpdateConversation: (
|
||||
conversation: Conversation,
|
||||
data: KeyValuePair,
|
||||
) => void;
|
||||
onEditMessage: (message: Message, messageIndex: number) => void;
|
||||
stopConversationRef: MutableRefObject<boolean>;
|
||||
}
|
||||
|
||||
export const Chat: FC<Props> = ({ conversation, models, messageIsStreaming, modelError, messageError, loading, lightMode, onSend, onUpdateConversation, stopConversationRef }) => {
|
||||
const [currentMessage, setCurrentMessage] = useState<Message>();
|
||||
export const Chat: FC<Props> = memo(
|
||||
({
|
||||
conversation,
|
||||
models,
|
||||
apiKey,
|
||||
serverSideApiKeyIsSet,
|
||||
defaultModelId,
|
||||
messageIsStreaming,
|
||||
modelError,
|
||||
loading,
|
||||
prompts,
|
||||
onSend,
|
||||
onUpdateConversation,
|
||||
onEditMessage,
|
||||
stopConversationRef,
|
||||
}) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const [currentMessage, setCurrentMessage] = useState<Message>();
|
||||
const [autoScrollEnabled, setAutoScrollEnabled] = useState<boolean>(true);
|
||||
const [showSettings, setShowSettings] = useState<boolean>(false);
|
||||
const [showScrollDownButton, setShowScrollDownButton] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const chatContainerRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "auto" });
|
||||
};
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (autoScrollEnabled) {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [autoScrollEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [conversation.messages]);
|
||||
const handleScroll = () => {
|
||||
if (chatContainerRef.current) {
|
||||
const { scrollTop, scrollHeight, clientHeight } =
|
||||
chatContainerRef.current;
|
||||
const bottomTolerance = 30;
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 overflow-none dark:bg-[#343541] bg-white">
|
||||
{modelError ? (
|
||||
<div className="flex flex-col justify-center mx-auto h-full w-[300px] sm:w-[500px] space-y-6">
|
||||
<div className="text-center text-red-500">Error fetching models.</div>
|
||||
<div className="text-center text-red-500">Make sure your OpenAI API key is set in the bottom left of the sidebar or in a .env.local file and refresh.</div>
|
||||
<div className="text-center text-red-500">If you completed this step, OpenAI may be experiencing issues.</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-scroll max-h-full">
|
||||
{conversation.messages.length === 0 ? (
|
||||
<>
|
||||
<div className="flex flex-col mx-auto pt-12 space-y-10 w-[350px] sm:w-[600px]">
|
||||
<div className="text-4xl font-semibold text-center text-gray-800 dark:text-gray-100">{models.length === 0 ? "Loading..." : "Chatbot UI"}</div>
|
||||
if (scrollTop + clientHeight < scrollHeight - bottomTolerance) {
|
||||
setAutoScrollEnabled(false);
|
||||
setShowScrollDownButton(true);
|
||||
} else {
|
||||
setAutoScrollEnabled(true);
|
||||
setShowScrollDownButton(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
{models.length > 0 && (
|
||||
<div className="flex flex-col h-full space-y-4 border p-4 rounded border-neutral-500">
|
||||
<ModelSelect
|
||||
model={conversation.model}
|
||||
models={models}
|
||||
onModelChange={(model) => onUpdateConversation(conversation, { key: "model", value: model })}
|
||||
/>
|
||||
const handleScrollDown = () => {
|
||||
chatContainerRef.current?.scrollTo({
|
||||
top: chatContainerRef.current.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
<SystemPrompt
|
||||
conversation={conversation}
|
||||
onChangePrompt={(prompt) => onUpdateConversation(conversation, { key: "prompt", value: prompt })}
|
||||
/>
|
||||
const handleSettings = () => {
|
||||
setShowSettings(!showSettings);
|
||||
};
|
||||
|
||||
const onClearAll = () => {
|
||||
if (confirm(t<string>('Are you sure you want to clear all messages?'))) {
|
||||
onUpdateConversation(conversation, { key: 'messages', value: [] });
|
||||
}
|
||||
};
|
||||
|
||||
const scrollDown = () => {
|
||||
if (autoScrollEnabled) {
|
||||
messagesEndRef.current?.scrollIntoView(true);
|
||||
}
|
||||
};
|
||||
const throttledScrollDown = throttle(scrollDown, 250);
|
||||
|
||||
useEffect(() => {
|
||||
throttledScrollDown();
|
||||
setCurrentMessage(
|
||||
conversation.messages[conversation.messages.length - 2],
|
||||
);
|
||||
}, [conversation.messages, throttledScrollDown]);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
setAutoScrollEnabled(entry.isIntersecting);
|
||||
if (entry.isIntersecting) {
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
},
|
||||
{
|
||||
root: null,
|
||||
threshold: 0.5,
|
||||
},
|
||||
);
|
||||
const messagesEndElement = messagesEndRef.current;
|
||||
if (messagesEndElement) {
|
||||
observer.observe(messagesEndElement);
|
||||
}
|
||||
return () => {
|
||||
if (messagesEndElement) {
|
||||
observer.unobserve(messagesEndElement);
|
||||
}
|
||||
};
|
||||
}, [messagesEndRef]);
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 overflow-hidden bg-white dark:bg-[#343541]">
|
||||
{!(apiKey || serverSideApiKeyIsSet) ? (
|
||||
<div className="mx-auto flex h-full w-[300px] flex-col justify-center space-y-6 sm:w-[600px]">
|
||||
<div className="text-center text-4xl font-bold text-black dark:text-white">
|
||||
Welcome to Chatbot UI
|
||||
</div>
|
||||
<div className="text-center text-lg text-black dark:text-white">
|
||||
<div className="mb-8">{`Chatbot UI is an open source clone of OpenAI's ChatGPT UI.`}</div>
|
||||
<div className="mb-2 font-bold">
|
||||
Important: Chatbot UI is 100% unaffiliated with OpenAI.
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||
<div className="mb-2">
|
||||
Chatbot UI allows you to plug in your API key to use this UI
|
||||
with their API.
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
It is <span className="italic">only</span> used to communicate
|
||||
with their API.
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
{t(
|
||||
'Please set your OpenAI API key in the bottom left of the sidebar.',
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"If you don't have an OpenAI API key, you can get one here: ",
|
||||
)}
|
||||
<a
|
||||
href="https://platform.openai.com/account/api-keys"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
openai.com
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : modelError ? (
|
||||
<ErrorMessageDiv error={modelError} />
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="max-h-full overflow-x-hidden"
|
||||
ref={chatContainerRef}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{conversation.messages.length === 0 ? (
|
||||
<>
|
||||
<div className="mx-auto flex w-[350px] flex-col space-y-10 pt-12 sm:w-[600px]">
|
||||
<div className="text-center text-3xl font-semibold text-gray-800 dark:text-gray-100">
|
||||
{models.length === 0 ? (
|
||||
<div>
|
||||
<Spinner size="16px" className="mx-auto" />
|
||||
</div>
|
||||
) : (
|
||||
'Chatbot UI'
|
||||
)}
|
||||
</div>
|
||||
|
||||
{models.length > 0 && (
|
||||
<div className="flex h-full flex-col space-y-4 rounded-lg border border-neutral-200 p-4 dark:border-neutral-600">
|
||||
<ModelSelect
|
||||
model={conversation.model}
|
||||
models={models}
|
||||
defaultModelId={defaultModelId}
|
||||
onModelChange={(model) =>
|
||||
onUpdateConversation(conversation, {
|
||||
key: 'model',
|
||||
value: model,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<SystemPrompt
|
||||
conversation={conversation}
|
||||
prompts={prompts}
|
||||
onChangePrompt={(prompt) =>
|
||||
onUpdateConversation(conversation, {
|
||||
key: 'prompt',
|
||||
value: prompt,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-center border border-b-neutral-300 bg-neutral-100 py-2 text-sm text-neutral-500 dark:border-none dark:bg-[#444654] dark:text-neutral-200">
|
||||
{t('Model')}: {conversation.model.name}
|
||||
<button
|
||||
className="ml-2 cursor-pointer hover:opacity-50"
|
||||
onClick={handleSettings}
|
||||
>
|
||||
<IconSettings size={18} />
|
||||
</button>
|
||||
<button
|
||||
className="ml-2 cursor-pointer hover:opacity-50"
|
||||
onClick={onClearAll}
|
||||
>
|
||||
<IconClearAll size={18} />
|
||||
</button>
|
||||
</div>
|
||||
{showSettings && (
|
||||
<div className="flex flex-col space-y-10 md:mx-auto md:max-w-xl md:gap-6 md:py-3 md:pt-6 lg:max-w-2xl lg:px-0 xl:max-w-3xl">
|
||||
<div className="flex h-full flex-col space-y-4 border-b border-neutral-200 p-4 dark:border-neutral-600 md:rounded-lg md:border">
|
||||
<ModelSelect
|
||||
model={conversation.model}
|
||||
models={models}
|
||||
defaultModelId={defaultModelId}
|
||||
onModelChange={(model) =>
|
||||
onUpdateConversation(conversation, {
|
||||
key: 'model',
|
||||
value: model,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-center py-2 text-neutral-500 bg-neutral-100 dark:bg-[#444654] dark:text-neutral-200 text-sm border border-b-neutral-300 dark:border-none">Model: {conversation.model.name}</div>
|
||||
|
||||
{conversation.messages.map((message, index) => (
|
||||
<ChatMessage
|
||||
key={index}
|
||||
message={message}
|
||||
lightMode={lightMode}
|
||||
{conversation.messages.map((message, index) => (
|
||||
<ChatMessage
|
||||
key={index}
|
||||
message={message}
|
||||
messageIndex={index}
|
||||
onEditMessage={onEditMessage}
|
||||
/>
|
||||
))}
|
||||
|
||||
{loading && <ChatLoader />}
|
||||
|
||||
<div
|
||||
className="h-[162px] bg-white dark:bg-[#343541]"
|
||||
ref={messagesEndRef}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && <ChatLoader />}
|
||||
|
||||
<div
|
||||
className="bg-white dark:bg-[#343541] h-[162px]"
|
||||
ref={messagesEndRef}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{messageError ? (
|
||||
<Regenerate
|
||||
<ChatInput
|
||||
stopConversationRef={stopConversationRef}
|
||||
textareaRef={textareaRef}
|
||||
messageIsStreaming={messageIsStreaming}
|
||||
conversationIsEmpty={conversation.messages.length === 0}
|
||||
model={conversation.model}
|
||||
prompts={prompts}
|
||||
onSend={(message, plugin) => {
|
||||
setCurrentMessage(message);
|
||||
onSend(message, 0, plugin);
|
||||
}}
|
||||
onRegenerate={() => {
|
||||
if (currentMessage) {
|
||||
onSend(currentMessage, true);
|
||||
onSend(currentMessage, 2, null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ChatInput
|
||||
stopConversationRef={stopConversationRef}
|
||||
messageIsStreaming={messageIsStreaming}
|
||||
onSend={(message) => {
|
||||
setCurrentMessage(message);
|
||||
onSend(message, false);
|
||||
}}
|
||||
model={conversation.model}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
</>
|
||||
)}
|
||||
{showScrollDownButton && (
|
||||
<div className="absolute bottom-0 right-0 mb-4 mr-4 pb-20">
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full bg-neutral-300 text-gray-800 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-neutral-200"
|
||||
onClick={handleScrollDown}
|
||||
>
|
||||
<IconArrowDown size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
Chat.displayName = 'Chat';
|
||||
|
||||
+292
-57
@@ -1,30 +1,83 @@
|
||||
import { Message, OpenAIModel, OpenAIModelID } from "@/types";
|
||||
import { IconPlayerStop, IconSend } from "@tabler/icons-react";
|
||||
import { FC, KeyboardEvent, MutableRefObject, useEffect, useRef, useState } from "react";
|
||||
import { Message } from '@/types/chat';
|
||||
import { OpenAIModel } from '@/types/openai';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { Prompt } from '@/types/prompt';
|
||||
import {
|
||||
IconBolt,
|
||||
IconBrandGoogle,
|
||||
IconPlayerStop,
|
||||
IconRepeat,
|
||||
IconSend,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import {
|
||||
FC,
|
||||
KeyboardEvent,
|
||||
MutableRefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { PluginSelect } from './PluginSelect';
|
||||
import { PromptList } from './PromptList';
|
||||
import { VariableModal } from './VariableModal';
|
||||
|
||||
interface Props {
|
||||
messageIsStreaming: boolean;
|
||||
onSend: (message: Message) => void;
|
||||
model: OpenAIModel;
|
||||
conversationIsEmpty: boolean;
|
||||
prompts: Prompt[];
|
||||
onSend: (message: Message, plugin: Plugin | null) => void;
|
||||
onRegenerate: () => void;
|
||||
stopConversationRef: MutableRefObject<boolean>;
|
||||
textareaRef: MutableRefObject<HTMLTextAreaElement | null>;
|
||||
}
|
||||
|
||||
export const ChatInput: FC<Props> = ({ onSend, messageIsStreaming, model, stopConversationRef }) => {
|
||||
export const ChatInput: FC<Props> = ({
|
||||
messageIsStreaming,
|
||||
model,
|
||||
conversationIsEmpty,
|
||||
prompts,
|
||||
onSend,
|
||||
onRegenerate,
|
||||
stopConversationRef,
|
||||
textareaRef,
|
||||
}) => {
|
||||
const { t } = useTranslation('chat');
|
||||
|
||||
const [content, setContent] = useState<string>();
|
||||
const [isTyping, setIsTyping] = useState<boolean>(false);
|
||||
const [showPromptList, setShowPromptList] = useState(false);
|
||||
const [activePromptIndex, setActivePromptIndex] = useState(0);
|
||||
const [promptInputValue, setPromptInputValue] = useState('');
|
||||
const [variables, setVariables] = useState<string[]>([]);
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [showPluginSelect, setShowPluginSelect] = useState(false);
|
||||
const [plugin, setPlugin] = useState<Plugin | null>(null);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const promptListRef = useRef<HTMLUListElement | null>(null);
|
||||
|
||||
const filteredPrompts = prompts.filter((prompt) =>
|
||||
prompt.name.toLowerCase().includes(promptInputValue.toLowerCase()),
|
||||
);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
const maxLength = model.id === OpenAIModelID.GPT_3_5 ? 12000 : 24000;
|
||||
const maxLength = model.maxLength;
|
||||
|
||||
if (value.length > maxLength) {
|
||||
alert(`Message limit is ${maxLength} characters`);
|
||||
alert(
|
||||
t(
|
||||
`Message limit is {{maxLength}} characters. You have entered {{valueLength}} characters.`,
|
||||
{ maxLength, valueLength: value.length },
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setContent(value);
|
||||
updatePromptListVisibility(value);
|
||||
};
|
||||
|
||||
const handleSend = () => {
|
||||
@@ -33,74 +86,231 @@ export const ChatInput: FC<Props> = ({ onSend, messageIsStreaming, model, stopCo
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
alert("Please enter a message");
|
||||
alert(t('Please enter a message'));
|
||||
return;
|
||||
}
|
||||
|
||||
onSend({ role: "user", content });
|
||||
setContent("");
|
||||
onSend({ role: 'user', content }, plugin);
|
||||
setContent('');
|
||||
setPlugin(null);
|
||||
|
||||
if (window.innerWidth < 640 && textareaRef && textareaRef.current) {
|
||||
textareaRef.current.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const isMobile = () => {
|
||||
const userAgent = typeof window.navigator === "undefined" ? "" : navigator.userAgent;
|
||||
const mobileRegex = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i;
|
||||
return mobileRegex.test(userAgent);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!isTyping) {
|
||||
if (e.key === "Enter" && !e.shiftKey && !isMobile()) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef && textareaRef.current) {
|
||||
textareaRef.current.style.height = "inherit";
|
||||
textareaRef.current.style.height = `${textareaRef.current?.scrollHeight}px`;
|
||||
textareaRef.current.style.overflow = `${textareaRef?.current?.scrollHeight > 400 ? "auto" : "hidden"}`;
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
function handleStopConversation() {
|
||||
const handleStopConversation = () => {
|
||||
stopConversationRef.current = true;
|
||||
setTimeout(() => {
|
||||
stopConversationRef.current = false;
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
const isMobile = () => {
|
||||
const userAgent =
|
||||
typeof window.navigator === 'undefined' ? '' : navigator.userAgent;
|
||||
const mobileRegex =
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i;
|
||||
return mobileRegex.test(userAgent);
|
||||
};
|
||||
|
||||
const handleInitModal = () => {
|
||||
const selectedPrompt = filteredPrompts[activePromptIndex];
|
||||
if (selectedPrompt) {
|
||||
setContent((prevContent) => {
|
||||
const newContent = prevContent?.replace(
|
||||
/\/\w*$/,
|
||||
selectedPrompt.content,
|
||||
);
|
||||
return newContent;
|
||||
});
|
||||
handlePromptSelect(selectedPrompt);
|
||||
}
|
||||
setShowPromptList(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (showPromptList) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActivePromptIndex((prevIndex) =>
|
||||
prevIndex < prompts.length - 1 ? prevIndex + 1 : prevIndex,
|
||||
);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActivePromptIndex((prevIndex) =>
|
||||
prevIndex > 0 ? prevIndex - 1 : prevIndex,
|
||||
);
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
setActivePromptIndex((prevIndex) =>
|
||||
prevIndex < prompts.length - 1 ? prevIndex + 1 : 0,
|
||||
);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleInitModal();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setShowPromptList(false);
|
||||
} else {
|
||||
setActivePromptIndex(0);
|
||||
}
|
||||
} else if (e.key === 'Enter' && !isTyping && !isMobile() && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
} else if (e.key === '/' && e.metaKey) {
|
||||
e.preventDefault();
|
||||
setShowPluginSelect(!showPluginSelect);
|
||||
}
|
||||
};
|
||||
|
||||
const parseVariables = (content: string) => {
|
||||
const regex = /{{(.*?)}}/g;
|
||||
const foundVariables = [];
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
foundVariables.push(match[1]);
|
||||
}
|
||||
|
||||
return foundVariables;
|
||||
};
|
||||
|
||||
const updatePromptListVisibility = useCallback((text: string) => {
|
||||
const match = text.match(/\/\w*$/);
|
||||
|
||||
if (match) {
|
||||
setShowPromptList(true);
|
||||
setPromptInputValue(match[0].slice(1));
|
||||
} else {
|
||||
setShowPromptList(false);
|
||||
setPromptInputValue('');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePromptSelect = (prompt: Prompt) => {
|
||||
const parsedVariables = parseVariables(prompt.content);
|
||||
setVariables(parsedVariables);
|
||||
|
||||
if (parsedVariables.length > 0) {
|
||||
setIsModalVisible(true);
|
||||
} else {
|
||||
setContent((prevContent) => {
|
||||
const updatedContent = prevContent?.replace(/\/\w*$/, prompt.content);
|
||||
return updatedContent;
|
||||
});
|
||||
updatePromptListVisibility(prompt.content);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (updatedVariables: string[]) => {
|
||||
const newContent = content?.replace(/{{(.*?)}}/g, (match, variable) => {
|
||||
const index = variables.indexOf(variable);
|
||||
return updatedVariables[index];
|
||||
});
|
||||
|
||||
setContent(newContent);
|
||||
|
||||
if (textareaRef && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (promptListRef.current) {
|
||||
promptListRef.current.scrollTop = activePromptIndex * 30;
|
||||
}
|
||||
}, [activePromptIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef && textareaRef.current) {
|
||||
textareaRef.current.style.height = 'inherit';
|
||||
textareaRef.current.style.height = `${textareaRef.current?.scrollHeight}px`;
|
||||
textareaRef.current.style.overflow = `${
|
||||
textareaRef?.current?.scrollHeight > 400 ? 'auto' : 'hidden'
|
||||
}`;
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOutsideClick = (e: MouseEvent) => {
|
||||
if (
|
||||
promptListRef.current &&
|
||||
!promptListRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setShowPromptList(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('click', handleOutsideClick);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('click', handleOutsideClick);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-0 left-0 w-full dark:border-white/20 border-transparent dark:bg-[#444654] dark:bg-gradient-to-t from-[#343541] via-[#343541] to-[#343541]/0 bg-white dark:!bg-transparent dark:bg-vert-dark-gradient pt-6 md:pt-2">
|
||||
<div className="stretch mx-2 md:mt-[52px] mt-4 flex flex-row gap-3 last:mb-2 md:mx-4 md:last:mb-6 lg:mx-auto lg:max-w-3xl">
|
||||
<div className="absolute bottom-0 left-0 w-full border-transparent bg-gradient-to-b from-transparent via-white to-white pt-6 dark:border-white/20 dark:via-[#343541] dark:to-[#343541] md:pt-2">
|
||||
<div className="stretch mx-2 mt-4 flex flex-row gap-3 last:mb-2 md:mx-4 md:mt-[52px] md:last:mb-6 lg:mx-auto lg:max-w-3xl">
|
||||
{messageIsStreaming && (
|
||||
<button
|
||||
className="absolute -top-2 md:top-0 left-0 right-0 mx-auto dark:bg-[#343541] border w-fit border-gray-500 py-2 px-4 rounded text-black dark:text-white hover:opacity-50"
|
||||
className="absolute top-0 left-0 right-0 mx-auto mb-3 flex w-fit items-center gap-3 rounded border border-neutral-200 bg-white py-2 px-4 text-black hover:opacity-50 dark:border-neutral-600 dark:bg-[#343541] dark:text-white md:mb-0 md:mt-2"
|
||||
onClick={handleStopConversation}
|
||||
>
|
||||
<IconPlayerStop
|
||||
size={16}
|
||||
className="inline-block mb-[2px]"
|
||||
/>{" "}
|
||||
Stop Generating
|
||||
<IconPlayerStop size={16} /> {t('Stop Generating')}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex flex-col w-full py-2 flex-grow md:py-3 md:pl-4 relative border border-black/10 bg-white dark:border-gray-900/50 dark:text-white dark:bg-[#40414F] rounded-md shadow-[0_0_10px_rgba(0,0,0,0.10)] dark:shadow-[0_0_15px_rgba(0,0,0,0.10)]">
|
||||
|
||||
{!messageIsStreaming && !conversationIsEmpty && (
|
||||
<button
|
||||
className="absolute top-0 left-0 right-0 mx-auto mb-3 flex w-fit items-center gap-3 rounded border border-neutral-200 bg-white py-2 px-4 text-black hover:opacity-50 dark:border-neutral-600 dark:bg-[#343541] dark:text-white md:mb-0 md:mt-2"
|
||||
onClick={onRegenerate}
|
||||
>
|
||||
<IconRepeat size={16} /> {t('Regenerate response')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="relative mx-2 flex w-full flex-grow flex-col rounded-md border border-black/10 bg-white shadow-[0_0_10px_rgba(0,0,0,0.10)] dark:border-gray-900/50 dark:bg-[#40414F] dark:text-white dark:shadow-[0_0_15px_rgba(0,0,0,0.10)] sm:mx-4">
|
||||
<button
|
||||
className="absolute left-2 top-2 rounded-sm p-1 text-neutral-800 opacity-60 hover:bg-neutral-200 hover:text-neutral-900 dark:bg-opacity-50 dark:text-neutral-100 dark:hover:text-neutral-200"
|
||||
onClick={() => setShowPluginSelect(!showPluginSelect)}
|
||||
onKeyDown={(e) => {}}
|
||||
>
|
||||
{plugin ? <IconBrandGoogle size={20} /> : <IconBolt size={20} />}
|
||||
</button>
|
||||
|
||||
{showPluginSelect && (
|
||||
<div className="absolute left-0 bottom-14 bg-white dark:bg-[#343541]">
|
||||
<PluginSelect
|
||||
plugin={plugin}
|
||||
onPluginChange={(plugin: Plugin) => {
|
||||
setPlugin(plugin);
|
||||
setShowPluginSelect(false);
|
||||
|
||||
if (textareaRef && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="text-black dark:text-white m-0 w-full resize-none outline-none border-0 bg-transparent p-0 pr-7 focus:ring-0 focus-visible:ring-0 dark:bg-transparent pl-2 md:pl-0"
|
||||
className="m-0 w-full resize-none border-0 bg-transparent p-0 py-2 pr-8 pl-10 text-black dark:bg-transparent dark:text-white md:py-3 md:pl-10"
|
||||
style={{
|
||||
resize: "none",
|
||||
resize: 'none',
|
||||
bottom: `${textareaRef?.current?.scrollHeight}px`,
|
||||
maxHeight: "400px",
|
||||
overflow: `${textareaRef.current && textareaRef.current.scrollHeight > 400 ? "auto" : "hidden"}`
|
||||
maxHeight: '400px',
|
||||
overflow: `${
|
||||
textareaRef.current && textareaRef.current.scrollHeight > 400
|
||||
? 'auto'
|
||||
: 'hidden'
|
||||
}`,
|
||||
}}
|
||||
placeholder="Type a message..."
|
||||
placeholder={
|
||||
t('Type a message or type "/" to select a prompt...') || ''
|
||||
}
|
||||
value={content}
|
||||
rows={1}
|
||||
onCompositionStart={() => setIsTyping(true)}
|
||||
@@ -110,17 +320,39 @@ export const ChatInput: FC<Props> = ({ onSend, messageIsStreaming, model, stopCo
|
||||
/>
|
||||
|
||||
<button
|
||||
className="absolute right-5 focus:outline-none text-neutral-800 hover:text-neutral-900 dark:text-neutral-100 dark:hover:text-neutral-200 dark:bg-opacity-50 hover:bg-neutral-200 p-1 rounded-sm"
|
||||
className="absolute right-2 top-2 rounded-sm p-1 text-neutral-800 opacity-60 hover:bg-neutral-200 hover:text-neutral-900 dark:bg-opacity-50 dark:text-neutral-100 dark:hover:text-neutral-200"
|
||||
onClick={handleSend}
|
||||
>
|
||||
<IconSend
|
||||
size={16}
|
||||
className="opacity-60"
|
||||
/>
|
||||
{messageIsStreaming ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-t-2 border-neutral-800 opacity-60 dark:border-neutral-100"></div>
|
||||
) : (
|
||||
<IconSend size={18} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showPromptList && filteredPrompts.length > 0 && (
|
||||
<div className="absolute bottom-12 w-full">
|
||||
<PromptList
|
||||
activePromptIndex={activePromptIndex}
|
||||
prompts={filteredPrompts}
|
||||
onSelect={handleInitModal}
|
||||
onMouseOver={setActivePromptIndex}
|
||||
promptListRef={promptListRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isModalVisible && (
|
||||
<VariableModal
|
||||
prompt={prompts[activePromptIndex]}
|
||||
variables={variables}
|
||||
onSubmit={handleSubmit}
|
||||
onClose={() => setIsModalVisible(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 pt-2 pb-3 text-center text-xs text-black/50 dark:text-white/50 md:px-4 md:pt-3 md:pb-6">
|
||||
<div className="px-3 pt-2 pb-3 text-center text-[12px] text-black/50 dark:text-white/50 md:px-4 md:pt-3 md:pb-6">
|
||||
<a
|
||||
href="https://github.com/mckaywrigley/chatbot-ui"
|
||||
target="_blank"
|
||||
@@ -129,7 +361,10 @@ export const ChatInput: FC<Props> = ({ onSend, messageIsStreaming, model, stopCo
|
||||
>
|
||||
ChatBot UI
|
||||
</a>
|
||||
. Chatbot UI is an advanced chatbot kit for OpenAI's chat models aiming to mimic ChatGPT's interface and functionality.
|
||||
.{' '}
|
||||
{t(
|
||||
"Chatbot UI is an advanced chatbot kit for OpenAI's chat models aiming to mimic ChatGPT's interface and functionality.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { IconDots } from "@tabler/icons-react";
|
||||
import { FC } from "react";
|
||||
import { IconDots } from '@tabler/icons-react';
|
||||
import { FC } from 'react';
|
||||
|
||||
interface Props {}
|
||||
|
||||
export const ChatLoader: FC<Props> = () => {
|
||||
return (
|
||||
<div
|
||||
className={`flex justify-center py-[20px] sm:py-[30px] whitespace-pre-wrap dark:bg-[#444654] dark:text-neutral-100 bg-neutral-100 text-neutral-900 dark:border-none"`}
|
||||
style={{ overflowWrap: "anywhere" }}
|
||||
className="group border-b border-black/10 bg-gray-50 text-gray-800 dark:border-gray-900/50 dark:bg-[#444654] dark:text-gray-100"
|
||||
style={{ overflowWrap: 'anywhere' }}
|
||||
>
|
||||
<div className="w-full sm:w-4/5 md:w-3/5 lg:w-[600px] xl:w-[800px] flex px-4">
|
||||
<div className="mr-1 sm:mr-2 font-bold min-w-[40px]">AI:</div>
|
||||
<div className="m-auto flex gap-4 p-4 text-base md:max-w-2xl md:gap-6 md:py-6 lg:max-w-2xl lg:px-0 xl:max-w-3xl">
|
||||
<div className="min-w-[40px] text-right font-bold">AI:</div>
|
||||
<IconDots className="animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+216
-54
@@ -1,65 +1,227 @@
|
||||
import { Message } from "@/types";
|
||||
import { FC } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { CodeBlock } from "../Markdown/CodeBlock";
|
||||
import { Message } from '@/types/chat';
|
||||
import { IconCheck, IconCopy, IconEdit, IconUser, IconRobot } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { FC, memo, useEffect, useRef, useState } from 'react';
|
||||
import rehypeMathjax from 'rehype-mathjax';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math';
|
||||
import { CodeBlock } from '../Markdown/CodeBlock';
|
||||
import { MemoizedReactMarkdown } from '../Markdown/MemoizedReactMarkdown';
|
||||
|
||||
interface Props {
|
||||
message: Message;
|
||||
lightMode: "light" | "dark";
|
||||
messageIndex: number;
|
||||
onEditMessage: (message: Message, messageIndex: number) => void;
|
||||
}
|
||||
|
||||
export const ChatMessage: FC<Props> = ({ message, lightMode }) => {
|
||||
return (
|
||||
<div
|
||||
className={`group ${message.role === "assistant" ? "text-gray-800 dark:text-gray-100 border-b border-black/10 dark:border-gray-900/50 bg-gray-50 dark:bg-[#444654]" : "text-gray-800 dark:text-gray-100 border-b border-black/10 dark:border-gray-900/50 bg-white dark:bg-[#343541]"}`}
|
||||
style={{ overflowWrap: "anywhere" }}
|
||||
>
|
||||
<div className="text-base gap-4 md:gap-6 md:max-w-2xl lg:max-w-2xl xl:max-w-3xl p-4 md:py-6 flex lg:px-0 m-auto">
|
||||
<div className="font-bold min-w-[40px]">{message.role === "assistant" ? "AI:" : "You:"}</div>
|
||||
export const ChatMessage: FC<Props> = memo(
|
||||
({ message, messageIndex, onEditMessage }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||
const [isTyping, setIsTyping] = useState<boolean>(false);
|
||||
const [messageContent, setMessageContent] = useState(message.content);
|
||||
const [messagedCopied, setMessageCopied] = useState(false);
|
||||
|
||||
<div className="prose dark:prose-invert mt-[-2px]">
|
||||
{message.role === "user" ? (
|
||||
<div className="prose dark:prose-invert whitespace-pre-wrap">{message.content}</div>
|
||||
) : (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({ node, inline, className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
return !inline && match ? (
|
||||
<CodeBlock
|
||||
key={Math.random()}
|
||||
language={match[1]}
|
||||
value={String(children).replace(/\n$/, "")}
|
||||
lightMode={lightMode}
|
||||
{...props}
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const toggleEditing = () => {
|
||||
setIsEditing(!isEditing);
|
||||
};
|
||||
|
||||
const handleInputChange = (
|
||||
event: React.ChangeEvent<HTMLTextAreaElement>,
|
||||
) => {
|
||||
setMessageContent(event.target.value);
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'inherit';
|
||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditMessage = () => {
|
||||
if (message.content != messageContent) {
|
||||
onEditMessage({ ...message, content: messageContent }, messageIndex);
|
||||
}
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handlePressEnter = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !isTyping && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleEditMessage();
|
||||
}
|
||||
};
|
||||
|
||||
const copyOnClick = () => {
|
||||
if (!navigator.clipboard) return;
|
||||
|
||||
navigator.clipboard.writeText(message.content).then(() => {
|
||||
setMessageCopied(true);
|
||||
setTimeout(() => {
|
||||
setMessageCopied(false);
|
||||
}, 2000);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'inherit';
|
||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group px-4 ${
|
||||
message.role === 'assistant'
|
||||
? 'border-b border-black/10 bg-gray-50 text-gray-800 dark:border-gray-900/50 dark:bg-[#444654] dark:text-gray-100'
|
||||
: 'border-b border-black/10 bg-white text-gray-800 dark:border-gray-900/50 dark:bg-[#343541] dark:text-gray-100'
|
||||
}`}
|
||||
style={{ overflowWrap: 'anywhere' }}
|
||||
>
|
||||
<div className="relative m-auto flex gap-4 p-4 text-base md:max-w-2xl md:gap-6 md:py-6 lg:max-w-2xl lg:px-0 xl:max-w-3xl">
|
||||
<div className="min-w-[40px] text-right font-bold">
|
||||
{message.role === 'assistant' ? <IconRobot size={30}/> : <IconUser size={30}/>}
|
||||
</div>
|
||||
|
||||
<div className="prose mt-[-2px] w-full dark:prose-invert">
|
||||
{message.role === 'user' ? (
|
||||
<div className="flex w-full">
|
||||
{isEditing ? (
|
||||
<div className="flex w-full flex-col">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="w-full resize-none whitespace-pre-wrap border-none dark:bg-[#343541]"
|
||||
value={messageContent}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handlePressEnter}
|
||||
onCompositionStart={() => setIsTyping(true)}
|
||||
onCompositionEnd={() => setIsTyping(false)}
|
||||
style={{
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 'inherit',
|
||||
lineHeight: 'inherit',
|
||||
padding: '0',
|
||||
margin: '0',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mt-10 flex justify-center space-x-4">
|
||||
<button
|
||||
className="h-[40px] rounded-md bg-blue-500 px-4 py-1 text-sm font-medium text-white enabled:hover:bg-blue-600 disabled:opacity-50"
|
||||
onClick={handleEditMessage}
|
||||
disabled={messageContent.trim().length <= 0}
|
||||
>
|
||||
{t('Save & Submit')}
|
||||
</button>
|
||||
<button
|
||||
className="h-[40px] rounded-md border border-neutral-300 px-4 py-1 text-sm font-medium text-neutral-700 hover:bg-neutral-100 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
||||
onClick={() => {
|
||||
setMessageContent(message.content);
|
||||
setIsEditing(false);
|
||||
}}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="prose whitespace-pre-wrap dark:prose-invert">
|
||||
{message.content}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(window.innerWidth < 640 || !isEditing) && (
|
||||
<button
|
||||
className={`absolute translate-x-[1000px] text-gray-500 hover:text-gray-700 focus:translate-x-0 group-hover:translate-x-0 dark:text-gray-400 dark:hover:text-gray-300 ${
|
||||
window.innerWidth < 640
|
||||
? 'right-3 bottom-1'
|
||||
: 'right-0 top-[26px]'
|
||||
}
|
||||
`}
|
||||
onClick={toggleEditing}
|
||||
>
|
||||
<IconEdit size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={`absolute ${
|
||||
window.innerWidth < 640
|
||||
? 'right-3 bottom-1'
|
||||
: 'right-0 top-[26px] m-0'
|
||||
}`}
|
||||
>
|
||||
{messagedCopied ? (
|
||||
<IconCheck
|
||||
size={20}
|
||||
className="text-green-500 dark:text-green-400"
|
||||
/>
|
||||
) : (
|
||||
<code
|
||||
className={className}
|
||||
{...props}
|
||||
<button
|
||||
className="translate-x-[1000px] text-gray-500 hover:text-gray-700 focus:translate-x-0 group-hover:translate-x-0 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
onClick={copyOnClick}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
table({ children }) {
|
||||
return <table className="border-collapse border border-black dark:border-white py-1 px-3">{children}</table>;
|
||||
},
|
||||
th({ children }) {
|
||||
return <th className="border border-black dark:border-white break-words py-1 px-3 bg-gray-500 text-white">{children}</th>;
|
||||
},
|
||||
td({ children }) {
|
||||
return <td className="border border-black dark:border-white break-words py-1 px-3">{children}</td>;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
<IconCopy size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MemoizedReactMarkdown
|
||||
className="prose dark:prose-invert"
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeMathjax]}
|
||||
components={{
|
||||
code({ node, inline, className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
|
||||
return !inline && match ? (
|
||||
<CodeBlock
|
||||
key={Math.random()}
|
||||
language={match[1]}
|
||||
value={String(children).replace(/\n$/, '')}
|
||||
{...props}
|
||||
/>
|
||||
) : (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
table({ children }) {
|
||||
return (
|
||||
<table className="border-collapse border border-black py-1 px-3 dark:border-white">
|
||||
{children}
|
||||
</table>
|
||||
);
|
||||
},
|
||||
th({ children }) {
|
||||
return (
|
||||
<th className="break-words border border-black bg-gray-500 py-1 px-3 text-white dark:border-white">
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
},
|
||||
td({ children }) {
|
||||
return (
|
||||
<td className="break-words border border-black py-1 px-3 dark:border-white">
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{message.content}
|
||||
</MemoizedReactMarkdown>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
);
|
||||
},
|
||||
);
|
||||
ChatMessage.displayName = 'ChatMessage';
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ErrorMessage } from '@/types/error';
|
||||
import { IconCircleX } from '@tabler/icons-react';
|
||||
import { FC } from 'react';
|
||||
|
||||
interface Props {
|
||||
error: ErrorMessage;
|
||||
}
|
||||
|
||||
export const ErrorMessageDiv: FC<Props> = ({ error }) => {
|
||||
return (
|
||||
<div className="mx-6 flex h-full flex-col items-center justify-center text-red-500">
|
||||
<div className="mb-5">
|
||||
<IconCircleX size={36} />
|
||||
</div>
|
||||
<div className="mb-3 text-2xl font-medium">{error.title}</div>
|
||||
{error.messageLines.map((line, index) => (
|
||||
<div key={index} className="text-center">
|
||||
{' '}
|
||||
{line}{' '}
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-4 text-xs opacity-50 dark:text-red-400">
|
||||
{error.code ? <i>Code: {error.code}</i> : ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,33 +1,60 @@
|
||||
import { OpenAIModel } from "@/types";
|
||||
import { FC } from "react";
|
||||
import { OpenAIModel, OpenAIModelID } from '@/types/openai';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { IconExternalLink } from '@tabler/icons-react';
|
||||
import { FC } from 'react';
|
||||
|
||||
interface Props {
|
||||
model: OpenAIModel;
|
||||
models: OpenAIModel[];
|
||||
defaultModelId: OpenAIModelID;
|
||||
onModelChange: (model: OpenAIModel) => void;
|
||||
}
|
||||
|
||||
export const ModelSelect: FC<Props> = ({ model, models, onModelChange }) => {
|
||||
export const ModelSelect: FC<Props> = ({
|
||||
model,
|
||||
models,
|
||||
defaultModelId,
|
||||
onModelChange,
|
||||
}) => {
|
||||
const { t } = useTranslation('chat');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<label className="text-left mb-2 dark:text-neutral-400 text-neutral-700">Model</label>
|
||||
<select
|
||||
className="w-full p-3 dark:text-white dark:bg-[#343541] border border-neutral-500 rounded-lg appearance-none focus:shadow-outline text-neutral-900 cursor-pointer"
|
||||
placeholder="Select a model"
|
||||
value={model.id}
|
||||
onChange={(e) => {
|
||||
onModelChange(models.find((model) => model.id === e.target.value) as OpenAIModel);
|
||||
}}
|
||||
>
|
||||
{models.map((model) => (
|
||||
<option
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
>
|
||||
{model.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="mb-2 text-left text-neutral-700 dark:text-neutral-400">
|
||||
{t('Model')}
|
||||
</label>
|
||||
<div className="w-full rounded-lg border border-neutral-200 bg-transparent pr-2 text-neutral-900 dark:border-neutral-600 dark:text-white">
|
||||
<select
|
||||
className="w-full bg-transparent p-2"
|
||||
placeholder={t('Select a model') || ''}
|
||||
value={model?.id || defaultModelId}
|
||||
onChange={(e) => {
|
||||
onModelChange(
|
||||
models.find(
|
||||
(model) => model.id === e.target.value,
|
||||
) as OpenAIModel,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{models.map((model) => (
|
||||
<option
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
className="dark:bg-[#343541] dark:text-white"
|
||||
>
|
||||
{model.id === defaultModelId
|
||||
? `Default (${model.name})`
|
||||
: model.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-full mt-3 text-left text-neutral-700 dark:text-neutral-400 flex items-center">
|
||||
<a href="https://platform.openai.com/account/usage" target="_blank" className="flex items-center">
|
||||
<IconExternalLink size={18} className={"inline mr-1"} />
|
||||
{t('View Account Usage')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Plugin, PluginList } from '@/types/plugin';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { FC, useEffect, useRef } from 'react';
|
||||
|
||||
interface Props {
|
||||
plugin: Plugin | null;
|
||||
onPluginChange: (plugin: Plugin) => void;
|
||||
}
|
||||
|
||||
export const PluginSelect: FC<Props> = ({ plugin, onPluginChange }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
|
||||
const selectRef = useRef<HTMLSelectElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectRef.current) {
|
||||
selectRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="w-full rounded-lg border border-neutral-200 bg-transparent pr-2 text-neutral-900 dark:border-neutral-600 dark:text-white">
|
||||
<select
|
||||
ref={selectRef}
|
||||
className="w-full cursor-pointer bg-transparent p-2"
|
||||
placeholder={t('Select a plugin') || ''}
|
||||
value={plugin?.id || ''}
|
||||
onChange={(e) => {
|
||||
onPluginChange(
|
||||
PluginList.find(
|
||||
(plugin) => plugin.id === e.target.value,
|
||||
) as Plugin,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option
|
||||
key="none"
|
||||
value=""
|
||||
className="dark:bg-[#343541] dark:text-white"
|
||||
>
|
||||
Select Plugin
|
||||
</option>
|
||||
|
||||
{PluginList.map((plugin) => (
|
||||
<option
|
||||
key={plugin.id}
|
||||
value={plugin.id}
|
||||
className="dark:bg-[#343541] dark:text-white"
|
||||
>
|
||||
{plugin.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Prompt } from '@/types/prompt';
|
||||
import { FC, MutableRefObject } from 'react';
|
||||
|
||||
interface Props {
|
||||
prompts: Prompt[];
|
||||
activePromptIndex: number;
|
||||
onSelect: () => void;
|
||||
onMouseOver: (index: number) => void;
|
||||
promptListRef: MutableRefObject<HTMLUListElement | null>;
|
||||
}
|
||||
|
||||
export const PromptList: FC<Props> = ({
|
||||
prompts,
|
||||
activePromptIndex,
|
||||
onSelect,
|
||||
onMouseOver,
|
||||
promptListRef,
|
||||
}) => {
|
||||
return (
|
||||
<ul
|
||||
ref={promptListRef}
|
||||
className="z-10 max-h-52 w-full overflow-scroll rounded border border-black/10 bg-white shadow-[0_0_10px_rgba(0,0,0,0.10)] dark:border-neutral-500 dark:bg-[#343541] dark:text-white dark:shadow-[0_0_15px_rgba(0,0,0,0.10)]"
|
||||
>
|
||||
{prompts.map((prompt, index) => (
|
||||
<li
|
||||
key={prompt.id}
|
||||
className={`${
|
||||
index === activePromptIndex
|
||||
? 'bg-gray-200 dark:bg-[#202123] dark:text-black'
|
||||
: ''
|
||||
} cursor-pointer px-3 py-2 text-sm text-black dark:text-white`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSelect();
|
||||
}}
|
||||
onMouseEnter={() => onMouseOver(index)}
|
||||
>
|
||||
{prompt.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
@@ -1,20 +1,24 @@
|
||||
import { IconRefresh } from "@tabler/icons-react";
|
||||
import { FC } from "react";
|
||||
import { IconRefresh } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { FC } from 'react';
|
||||
|
||||
interface Props {
|
||||
onRegenerate: () => void;
|
||||
}
|
||||
|
||||
export const Regenerate: FC<Props> = ({ onRegenerate }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
return (
|
||||
<div className="fixed sm:absolute bottom-4 sm:bottom-8 w-full sm:w-1/2 px-2 left-0 sm:left-[280px] lg:left-[200px] right-0 ml-auto mr-auto">
|
||||
<div className="text-center mb-4 text-red-500">Sorry, there was an error.</div>
|
||||
<div className="fixed bottom-4 left-0 right-0 ml-auto mr-auto w-full px-2 sm:absolute sm:bottom-8 sm:left-[280px] sm:w-1/2 lg:left-[200px]">
|
||||
<div className="mb-4 text-center text-red-500">
|
||||
{t('Sorry, there was an error.')}
|
||||
</div>
|
||||
<button
|
||||
className="flex items-center justify-center w-full h-12 bg-neutral-100 dark:bg-[#444654] text-neutral-500 dark:text-neutral-200 text-sm font-semibold rounded-lg border border-b-neutral-300 dark:border-none"
|
||||
className="flex h-12 gap-2 w-full items-center justify-center rounded-lg border border-b-neutral-300 bg-neutral-100 text-sm font-semibold text-neutral-500 dark:border-none dark:bg-[#444654] dark:text-neutral-200"
|
||||
onClick={onRegenerate}
|
||||
>
|
||||
<IconRefresh className="mr-2" />
|
||||
<div>Regenerate response</div>
|
||||
<IconRefresh />
|
||||
<div>{t('Regenerate response')}</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,36 +1,164 @@
|
||||
import { Conversation } from "@/types";
|
||||
import { DEFAULT_SYSTEM_PROMPT } from "@/utils/app/const";
|
||||
import { FC, useEffect, useRef, useState } from "react";
|
||||
import { Conversation } from '@/types/chat';
|
||||
import { OpenAIModelID } from '@/types/openai';
|
||||
import { Prompt } from '@/types/prompt';
|
||||
import { DEFAULT_SYSTEM_PROMPT } from '@/utils/app/const';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import {
|
||||
FC,
|
||||
KeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { PromptList } from './PromptList';
|
||||
import { VariableModal } from './VariableModal';
|
||||
|
||||
interface Props {
|
||||
conversation: Conversation;
|
||||
prompts: Prompt[];
|
||||
onChangePrompt: (prompt: string) => void;
|
||||
}
|
||||
|
||||
export const SystemPrompt: FC<Props> = ({ conversation, onChangePrompt }) => {
|
||||
const [value, setValue] = useState<string>("");
|
||||
export const SystemPrompt: FC<Props> = ({
|
||||
conversation,
|
||||
prompts,
|
||||
onChangePrompt,
|
||||
}) => {
|
||||
const { t } = useTranslation('chat');
|
||||
|
||||
const [value, setValue] = useState<string>('');
|
||||
const [activePromptIndex, setActivePromptIndex] = useState(0);
|
||||
const [showPromptList, setShowPromptList] = useState(false);
|
||||
const [promptInputValue, setPromptInputValue] = useState('');
|
||||
const [variables, setVariables] = useState<string[]>([]);
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const promptListRef = useRef<HTMLUListElement | null>(null);
|
||||
|
||||
const filteredPrompts = prompts.filter((prompt) =>
|
||||
prompt.name.toLowerCase().includes(promptInputValue.toLowerCase()),
|
||||
);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
const maxLength = 4000;
|
||||
const maxLength = conversation.model.maxLength;
|
||||
|
||||
if (value.length > maxLength) {
|
||||
alert(`Prompt limit is ${maxLength} characters`);
|
||||
alert(
|
||||
t(
|
||||
`Prompt limit is {{maxLength}} characters. You have entered {{valueLength}} characters.`,
|
||||
{ maxLength, valueLength: value.length },
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setValue(value);
|
||||
updatePromptListVisibility(value);
|
||||
|
||||
if (value.length > 0) {
|
||||
onChangePrompt(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInitModal = () => {
|
||||
const selectedPrompt = filteredPrompts[activePromptIndex];
|
||||
setValue((prevVal) => {
|
||||
const newContent = prevVal?.replace(/\/\w*$/, selectedPrompt.content);
|
||||
return newContent;
|
||||
});
|
||||
handlePromptSelect(selectedPrompt);
|
||||
setShowPromptList(false);
|
||||
};
|
||||
|
||||
const parseVariables = (content: string) => {
|
||||
const regex = /{{(.*?)}}/g;
|
||||
const foundVariables = [];
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
foundVariables.push(match[1]);
|
||||
}
|
||||
|
||||
return foundVariables;
|
||||
};
|
||||
|
||||
const updatePromptListVisibility = useCallback((text: string) => {
|
||||
const match = text.match(/\/\w*$/);
|
||||
|
||||
if (match) {
|
||||
setShowPromptList(true);
|
||||
setPromptInputValue(match[0].slice(1));
|
||||
} else {
|
||||
setShowPromptList(false);
|
||||
setPromptInputValue('');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePromptSelect = (prompt: Prompt) => {
|
||||
const parsedVariables = parseVariables(prompt.content);
|
||||
setVariables(parsedVariables);
|
||||
|
||||
if (parsedVariables.length > 0) {
|
||||
setIsModalVisible(true);
|
||||
} else {
|
||||
const updatedContent = value?.replace(/\/\w*$/, prompt.content);
|
||||
|
||||
setValue(updatedContent);
|
||||
onChangePrompt(updatedContent);
|
||||
|
||||
updatePromptListVisibility(prompt.content);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (updatedVariables: string[]) => {
|
||||
const newContent = value?.replace(/{{(.*?)}}/g, (match, variable) => {
|
||||
const index = variables.indexOf(variable);
|
||||
return updatedVariables[index];
|
||||
});
|
||||
|
||||
setValue(newContent);
|
||||
onChangePrompt(newContent);
|
||||
|
||||
if (textareaRef && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (showPromptList) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActivePromptIndex((prevIndex) =>
|
||||
prevIndex < prompts.length - 1 ? prevIndex + 1 : prevIndex,
|
||||
);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActivePromptIndex((prevIndex) =>
|
||||
prevIndex > 0 ? prevIndex - 1 : prevIndex,
|
||||
);
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
setActivePromptIndex((prevIndex) =>
|
||||
prevIndex < prompts.length - 1 ? prevIndex + 1 : 0,
|
||||
);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleInitModal();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setShowPromptList(false);
|
||||
} else {
|
||||
setActivePromptIndex(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef && textareaRef.current) {
|
||||
textareaRef.current.style.height = "inherit";
|
||||
textareaRef.current.style.height = 'inherit';
|
||||
textareaRef.current.style.height = `${textareaRef.current?.scrollHeight}px`;
|
||||
}
|
||||
}, [value]);
|
||||
@@ -43,23 +171,70 @@ export const SystemPrompt: FC<Props> = ({ conversation, onChangePrompt }) => {
|
||||
}
|
||||
}, [conversation]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOutsideClick = (e: MouseEvent) => {
|
||||
if (
|
||||
promptListRef.current &&
|
||||
!promptListRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setShowPromptList(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('click', handleOutsideClick);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('click', handleOutsideClick);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<label className="text-left dark:text-neutral-400 text-neutral-700 mb-2">System Prompt</label>
|
||||
<label className="mb-2 text-left text-neutral-700 dark:text-neutral-400">
|
||||
{t('System Prompt')}
|
||||
</label>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="w-full rounded-lg px-4 py-2 focus:outline-none dark:bg-[#40414F] dark:border-opacity-50 dark:border-neutral-800 dark:text-neutral-100 border border-neutral-500 shadow text-neutral-900"
|
||||
className="w-full rounded-lg border border-neutral-200 bg-transparent px-4 py-3 text-neutral-900 dark:border-neutral-600 dark:text-neutral-100"
|
||||
style={{
|
||||
resize: "none",
|
||||
resize: 'none',
|
||||
bottom: `${textareaRef?.current?.scrollHeight}px`,
|
||||
maxHeight: "300px",
|
||||
overflow: `${textareaRef.current && textareaRef.current.scrollHeight > 400 ? "auto" : "hidden"}`
|
||||
maxHeight: '300px',
|
||||
overflow: `${
|
||||
textareaRef.current && textareaRef.current.scrollHeight > 400
|
||||
? 'auto'
|
||||
: 'hidden'
|
||||
}`,
|
||||
}}
|
||||
placeholder="Enter a prompt"
|
||||
value={value}
|
||||
placeholder={
|
||||
t(`Enter a prompt or type "/" to select a prompt...`) || ''
|
||||
}
|
||||
value={t(value) || ''}
|
||||
rows={1}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
|
||||
{showPromptList && filteredPrompts.length > 0 && (
|
||||
<div>
|
||||
<PromptList
|
||||
activePromptIndex={activePromptIndex}
|
||||
prompts={filteredPrompts}
|
||||
onSelect={handleInitModal}
|
||||
onMouseOver={setActivePromptIndex}
|
||||
promptListRef={promptListRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isModalVisible && (
|
||||
<VariableModal
|
||||
prompt={prompts[activePromptIndex]}
|
||||
variables={variables}
|
||||
onSubmit={handleSubmit}
|
||||
onClose={() => setIsModalVisible(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Prompt } from '@/types/prompt';
|
||||
import { FC, KeyboardEvent, useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
prompt: Prompt;
|
||||
variables: string[];
|
||||
onSubmit: (updatedVariables: string[]) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const VariableModal: FC<Props> = ({
|
||||
prompt,
|
||||
variables,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}) => {
|
||||
const [updatedVariables, setUpdatedVariables] = useState<
|
||||
{ key: string; value: string }[]
|
||||
>(
|
||||
variables
|
||||
.map((variable) => ({ key: variable, value: '' }))
|
||||
.filter(
|
||||
(item, index, array) =>
|
||||
array.findIndex((t) => t.key === item.key) === index,
|
||||
),
|
||||
);
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const nameInputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const handleChange = (index: number, value: string) => {
|
||||
setUpdatedVariables((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[index].value = value;
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (updatedVariables.some((variable) => variable.value === '')) {
|
||||
alert('Please fill out all variables');
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit(updatedVariables.map((variable) => variable.value));
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
} else if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleOutsideClick = (e: MouseEvent) => {
|
||||
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('click', handleOutsideClick);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('click', handleOutsideClick);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (nameInputRef.current) {
|
||||
nameInputRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="dark:border-netural-400 inline-block max-h-[400px] transform overflow-hidden overflow-y-auto rounded-lg border border-gray-300 bg-white px-4 pt-5 pb-4 text-left align-bottom shadow-xl transition-all dark:bg-[#202123] sm:my-8 sm:max-h-[600px] sm:w-full sm:max-w-lg sm:p-6 sm:align-middle"
|
||||
role="dialog"
|
||||
>
|
||||
<div className="mb-4 text-xl font-bold text-black dark:text-neutral-200">
|
||||
{prompt.name}
|
||||
</div>
|
||||
|
||||
<div className="mb-4 text-sm italic text-black dark:text-neutral-200">
|
||||
{prompt.description}
|
||||
</div>
|
||||
|
||||
{updatedVariables.map((variable, index) => (
|
||||
<div className="mb-4" key={index}>
|
||||
<div className="mb-2 text-sm font-bold text-neutral-200">
|
||||
{variable.key}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref={index === 0 ? nameInputRef : undefined}
|
||||
className="mt-1 w-full rounded-lg border border-neutral-500 px-4 py-2 text-neutral-900 shadow focus:outline-none dark:border-neutral-800 dark:border-opacity-50 dark:bg-[#40414F] dark:text-neutral-100"
|
||||
style={{ resize: 'none' }}
|
||||
placeholder={`Enter a value for ${variable.key}...`}
|
||||
value={variable.value}
|
||||
onChange={(e) => handleChange(index, e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
className="mt-6 w-full rounded-lg border border-neutral-500 px-4 py-2 text-neutral-900 shadow hover:bg-neutral-100 focus:outline-none dark:border-neutral-800 dark:border-opacity-50 dark:bg-white dark:text-black dark:hover:bg-neutral-300"
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,213 @@
|
||||
import { Conversation } from '@/types/chat';
|
||||
import { KeyValuePair } from '@/types/data';
|
||||
import { SupportedExportFormats } from '@/types/export';
|
||||
import { Folder } from '@/types/folder';
|
||||
import { PluginKey } from '@/types/plugin';
|
||||
import { IconFolderPlus, IconMessagesOff, IconPlus } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { ChatFolders } from '../Folders/Chat/ChatFolders';
|
||||
import { Search } from '../Sidebar/Search';
|
||||
import { ChatbarSettings } from './ChatbarSettings';
|
||||
import { Conversations } from './Conversations';
|
||||
|
||||
interface Props {
|
||||
loading: boolean;
|
||||
conversations: Conversation[];
|
||||
lightMode: 'light' | 'dark';
|
||||
selectedConversation: Conversation;
|
||||
apiKey: string;
|
||||
pluginKeys: PluginKey[];
|
||||
folders: Folder[];
|
||||
onCreateFolder: (name: string) => void;
|
||||
onDeleteFolder: (folderId: string) => void;
|
||||
onUpdateFolder: (folderId: string, name: string) => void;
|
||||
onNewConversation: () => void;
|
||||
onToggleLightMode: (mode: 'light' | 'dark') => void;
|
||||
onSelectConversation: (conversation: Conversation) => void;
|
||||
onDeleteConversation: (conversation: Conversation) => void;
|
||||
onUpdateConversation: (
|
||||
conversation: Conversation,
|
||||
data: KeyValuePair,
|
||||
) => void;
|
||||
onApiKeyChange: (apiKey: string) => void;
|
||||
onClearConversations: () => void;
|
||||
onExportConversations: () => void;
|
||||
onImportConversations: (data: SupportedExportFormats) => void;
|
||||
onPluginKeyChange: (pluginKey: PluginKey) => void;
|
||||
onClearPluginKey: (pluginKey: PluginKey) => void;
|
||||
}
|
||||
|
||||
export const Chatbar: FC<Props> = ({
|
||||
loading,
|
||||
conversations,
|
||||
lightMode,
|
||||
selectedConversation,
|
||||
apiKey,
|
||||
pluginKeys,
|
||||
folders,
|
||||
onCreateFolder,
|
||||
onDeleteFolder,
|
||||
onUpdateFolder,
|
||||
onNewConversation,
|
||||
onToggleLightMode,
|
||||
onSelectConversation,
|
||||
onDeleteConversation,
|
||||
onUpdateConversation,
|
||||
onApiKeyChange,
|
||||
onClearConversations,
|
||||
onExportConversations,
|
||||
onImportConversations,
|
||||
onPluginKeyChange,
|
||||
onClearPluginKey,
|
||||
}) => {
|
||||
const { t } = useTranslation('sidebar');
|
||||
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||
const [filteredConversations, setFilteredConversations] =
|
||||
useState<Conversation[]>(conversations);
|
||||
|
||||
const handleUpdateConversation = (
|
||||
conversation: Conversation,
|
||||
data: KeyValuePair,
|
||||
) => {
|
||||
onUpdateConversation(conversation, data);
|
||||
setSearchTerm('');
|
||||
};
|
||||
|
||||
const handleDeleteConversation = (conversation: Conversation) => {
|
||||
onDeleteConversation(conversation);
|
||||
setSearchTerm('');
|
||||
};
|
||||
|
||||
const handleDrop = (e: any) => {
|
||||
if (e.dataTransfer) {
|
||||
const conversation = JSON.parse(e.dataTransfer.getData('conversation'));
|
||||
onUpdateConversation(conversation, { key: 'folderId', value: 0 });
|
||||
|
||||
e.target.style.background = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
const allowDrop = (e: any) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const highlightDrop = (e: any) => {
|
||||
e.target.style.background = '#343541';
|
||||
};
|
||||
|
||||
const removeHighlight = (e: any) => {
|
||||
e.target.style.background = 'none';
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (searchTerm) {
|
||||
setFilteredConversations(
|
||||
conversations.filter((conversation) => {
|
||||
const searchable =
|
||||
conversation.name.toLocaleLowerCase() +
|
||||
' ' +
|
||||
conversation.messages.map((message) => message.content).join(' ');
|
||||
return searchable.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
setFilteredConversations(conversations);
|
||||
}
|
||||
}, [searchTerm, conversations]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed top-0 bottom-0 z-50 flex h-full w-[260px] flex-none flex-col space-y-2 bg-[#202123] p-2 transition-all sm:relative sm:top-0`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
className="flex w-[190px] flex-shrink-0 cursor-pointer select-none items-center gap-3 rounded-md border border-white/20 p-3 text-[14px] leading-normal text-white transition-colors duration-200 hover:bg-gray-500/10"
|
||||
onClick={() => {
|
||||
onNewConversation();
|
||||
setSearchTerm('');
|
||||
}}
|
||||
>
|
||||
<IconPlus size={18} />
|
||||
{t('New chat')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="ml-2 flex flex-shrink-0 cursor-pointer items-center gap-3 rounded-md border border-white/20 p-3 text-[14px] leading-normal text-white transition-colors duration-200 hover:bg-gray-500/10"
|
||||
onClick={() => onCreateFolder(t('New folder'))}
|
||||
>
|
||||
<IconFolderPlus size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{conversations.length > 1 && (
|
||||
<Search
|
||||
placeholder="Search conversations..."
|
||||
searchTerm={searchTerm}
|
||||
onSearch={setSearchTerm}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex-grow overflow-auto">
|
||||
{folders.length > 0 && (
|
||||
<div className="flex border-b border-white/20 pb-2">
|
||||
<ChatFolders
|
||||
searchTerm={searchTerm}
|
||||
conversations={filteredConversations.filter(
|
||||
(conversation) => conversation.folderId,
|
||||
)}
|
||||
folders={folders}
|
||||
onDeleteFolder={onDeleteFolder}
|
||||
onUpdateFolder={onUpdateFolder}
|
||||
selectedConversation={selectedConversation}
|
||||
loading={loading}
|
||||
onSelectConversation={onSelectConversation}
|
||||
onDeleteConversation={handleDeleteConversation}
|
||||
onUpdateConversation={handleUpdateConversation}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{conversations.length > 0 ? (
|
||||
<div
|
||||
className="pt-2"
|
||||
onDrop={(e) => handleDrop(e)}
|
||||
onDragOver={allowDrop}
|
||||
onDragEnter={highlightDrop}
|
||||
onDragLeave={removeHighlight}
|
||||
>
|
||||
<Conversations
|
||||
loading={loading}
|
||||
conversations={filteredConversations.filter(
|
||||
(conversation) => !conversation.folderId,
|
||||
)}
|
||||
selectedConversation={selectedConversation}
|
||||
onSelectConversation={onSelectConversation}
|
||||
onDeleteConversation={handleDeleteConversation}
|
||||
onUpdateConversation={handleUpdateConversation}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-8 flex flex-col items-center gap-3 text-sm leading-normal text-white opacity-50">
|
||||
<IconMessagesOff />
|
||||
{t('No conversations.')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChatbarSettings
|
||||
lightMode={lightMode}
|
||||
apiKey={apiKey}
|
||||
pluginKeys={pluginKeys}
|
||||
conversationsCount={conversations.length}
|
||||
onToggleLightMode={onToggleLightMode}
|
||||
onApiKeyChange={onApiKeyChange}
|
||||
onClearConversations={onClearConversations}
|
||||
onExportConversations={onExportConversations}
|
||||
onImportConversations={onImportConversations}
|
||||
onPluginKeyChange={onPluginKeyChange}
|
||||
onClearPluginKey={onClearPluginKey}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import { SupportedExportFormats } from '@/types/export';
|
||||
import { PluginKey } from '@/types/plugin';
|
||||
import { IconFileExport, IconMoon, IconSun } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { FC } from 'react';
|
||||
import { Import } from '../Settings/Import';
|
||||
import { Key } from '../Settings/Key';
|
||||
import { SidebarButton } from '../Sidebar/SidebarButton';
|
||||
import { ClearConversations } from './ClearConversations';
|
||||
import { PluginKeys } from './PluginKeys';
|
||||
|
||||
interface Props {
|
||||
lightMode: 'light' | 'dark';
|
||||
apiKey: string;
|
||||
pluginKeys: PluginKey[];
|
||||
conversationsCount: number;
|
||||
onToggleLightMode: (mode: 'light' | 'dark') => void;
|
||||
onApiKeyChange: (apiKey: string) => void;
|
||||
onClearConversations: () => void;
|
||||
onExportConversations: () => void;
|
||||
onImportConversations: (data: SupportedExportFormats) => void;
|
||||
onPluginKeyChange: (pluginKey: PluginKey) => void;
|
||||
onClearPluginKey: (pluginKey: PluginKey) => void;
|
||||
}
|
||||
|
||||
export const ChatbarSettings: FC<Props> = ({
|
||||
lightMode,
|
||||
apiKey,
|
||||
pluginKeys,
|
||||
conversationsCount,
|
||||
onToggleLightMode,
|
||||
onApiKeyChange,
|
||||
onClearConversations,
|
||||
onExportConversations,
|
||||
onImportConversations,
|
||||
onPluginKeyChange,
|
||||
onClearPluginKey,
|
||||
}) => {
|
||||
const { t } = useTranslation('sidebar');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-1 border-t border-white/20 pt-1 text-sm">
|
||||
{conversationsCount > 0 ? (
|
||||
<ClearConversations onClearConversations={onClearConversations} />
|
||||
) : null}
|
||||
|
||||
<Import onImport={onImportConversations} />
|
||||
|
||||
<SidebarButton
|
||||
text={t('Export data')}
|
||||
icon={<IconFileExport size={18} />}
|
||||
onClick={() => onExportConversations()}
|
||||
/>
|
||||
|
||||
<SidebarButton
|
||||
text={lightMode === 'light' ? t('Dark mode') : t('Light mode')}
|
||||
icon={
|
||||
lightMode === 'light' ? <IconMoon size={18} /> : <IconSun size={18} />
|
||||
}
|
||||
onClick={() =>
|
||||
onToggleLightMode(lightMode === 'light' ? 'dark' : 'light')
|
||||
}
|
||||
/>
|
||||
|
||||
<Key apiKey={apiKey} onApiKeyChange={onApiKeyChange} />
|
||||
|
||||
<PluginKeys
|
||||
pluginKeys={pluginKeys}
|
||||
onPluginKeyChange={onPluginKeyChange}
|
||||
onClearPluginKey={onClearPluginKey}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+14
-9
@@ -1,6 +1,7 @@
|
||||
import { IconCheck, IconTrash, IconX } from "@tabler/icons-react";
|
||||
import { FC, useState } from "react";
|
||||
import { SidebarButton } from "./SidebarButton";
|
||||
import { IconCheck, IconTrash, IconX } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { FC, useState } from 'react';
|
||||
import { SidebarButton } from '../Sidebar/SidebarButton';
|
||||
|
||||
interface Props {
|
||||
onClearConversations: () => void;
|
||||
@@ -9,20 +10,24 @@ interface Props {
|
||||
export const ClearConversations: FC<Props> = ({ onClearConversations }) => {
|
||||
const [isConfirming, setIsConfirming] = useState<boolean>(false);
|
||||
|
||||
const { t } = useTranslation('sidebar');
|
||||
|
||||
const handleClearConversations = () => {
|
||||
onClearConversations();
|
||||
setIsConfirming(false);
|
||||
};
|
||||
|
||||
return isConfirming ? (
|
||||
<div className="flex hover:bg-[#343541] py-2 px-2 rounded-md cursor-pointer w-full items-center">
|
||||
<IconTrash size={16} />
|
||||
<div className="flex w-full cursor-pointer items-center rounded-lg py-3 px-3 hover:bg-gray-500/10">
|
||||
<IconTrash size={18} />
|
||||
|
||||
<div className="ml-2 flex-1 text-left text-white">Are you sure?</div>
|
||||
<div className="ml-3 flex-1 text-left text-[12.5px] leading-3 text-white">
|
||||
{t('Are you sure?')}
|
||||
</div>
|
||||
|
||||
<div className="flex w-[40px]">
|
||||
<IconCheck
|
||||
className="ml-auto min-w-[20px] text-neutral-400 hover:text-neutral-100"
|
||||
className="ml-auto min-w-[20px] mr-1 text-neutral-400 hover:text-neutral-100"
|
||||
size={18}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -42,8 +47,8 @@ export const ClearConversations: FC<Props> = ({ onClearConversations }) => {
|
||||
</div>
|
||||
) : (
|
||||
<SidebarButton
|
||||
text="Clear conversations"
|
||||
icon={<IconTrash size={16} />}
|
||||
text={t('Clear conversations')}
|
||||
icon={<IconTrash size={18} />}
|
||||
onClick={() => setIsConfirming(true)}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,163 @@
|
||||
import { Conversation } from '@/types/chat';
|
||||
import { KeyValuePair } from '@/types/data';
|
||||
import {
|
||||
IconCheck,
|
||||
IconMessage,
|
||||
IconPencil,
|
||||
IconTrash,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { DragEvent, FC, KeyboardEvent, useEffect, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
selectedConversation: Conversation;
|
||||
conversation: Conversation;
|
||||
loading: boolean;
|
||||
onSelectConversation: (conversation: Conversation) => void;
|
||||
onDeleteConversation: (conversation: Conversation) => void;
|
||||
onUpdateConversation: (
|
||||
conversation: Conversation,
|
||||
data: KeyValuePair,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const ConversationComponent: FC<Props> = ({
|
||||
selectedConversation,
|
||||
conversation,
|
||||
loading,
|
||||
onSelectConversation,
|
||||
onDeleteConversation,
|
||||
onUpdateConversation,
|
||||
}) => {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
|
||||
const handleEnterDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleRename(selectedConversation);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragStart = (
|
||||
e: DragEvent<HTMLButtonElement>,
|
||||
conversation: Conversation,
|
||||
) => {
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.setData('conversation', JSON.stringify(conversation));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRename = (conversation: Conversation) => {
|
||||
if (renameValue.trim().length > 0) {
|
||||
onUpdateConversation(conversation, { key: 'name', value: renameValue });
|
||||
setRenameValue('');
|
||||
setIsRenaming(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isRenaming) {
|
||||
setIsDeleting(false);
|
||||
} else if (isDeleting) {
|
||||
setIsRenaming(false);
|
||||
}
|
||||
}, [isRenaming, isDeleting]);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center">
|
||||
{isRenaming && selectedConversation.id === conversation.id ? (
|
||||
<div className="flex w-full items-center gap-3 bg-[#343541]/90 p-3 rounded-lg">
|
||||
<IconMessage size={18} />
|
||||
<input
|
||||
className="mr-12 flex-1 overflow-hidden overflow-ellipsis border-neutral-400 bg-transparent text-left text-[12.5px] leading-3 text-white outline-none focus:border-neutral-100"
|
||||
type="text"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={handleEnterDown}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className={`flex w-full cursor-pointer items-center gap-3 rounded-lg p-3 text-sm transition-colors duration-200 hover:bg-[#343541]/90 ${
|
||||
loading ? 'disabled:cursor-not-allowed' : ''
|
||||
} ${
|
||||
selectedConversation.id === conversation.id ? 'bg-[#343541]/90' : ''
|
||||
}`}
|
||||
onClick={() => onSelectConversation(conversation)}
|
||||
disabled={loading}
|
||||
draggable="true"
|
||||
onDragStart={(e) => handleDragStart(e, conversation)}
|
||||
>
|
||||
<IconMessage size={18} />
|
||||
<div
|
||||
className={`relative max-h-5 flex-1 overflow-hidden text-ellipsis whitespace-nowrap break-all text-left text-[12.5px] leading-3 ${
|
||||
selectedConversation.id === conversation.id ? 'pr-12' : 'pr-1'
|
||||
}`}
|
||||
>
|
||||
{conversation.name}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(isDeleting || isRenaming) &&
|
||||
selectedConversation.id === conversation.id && (
|
||||
<div className="absolute right-1 z-10 flex text-gray-300">
|
||||
<button
|
||||
className="min-w-[20px] p-1 text-neutral-400 hover:text-neutral-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isDeleting) {
|
||||
onDeleteConversation(conversation);
|
||||
} else if (isRenaming) {
|
||||
handleRename(conversation);
|
||||
}
|
||||
setIsDeleting(false);
|
||||
setIsRenaming(false);
|
||||
}}
|
||||
>
|
||||
<IconCheck size={18} />
|
||||
</button>
|
||||
<button
|
||||
className="min-w-[20px] p-1 text-neutral-400 hover:text-neutral-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDeleting(false);
|
||||
setIsRenaming(false);
|
||||
}}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedConversation.id === conversation.id &&
|
||||
!isDeleting &&
|
||||
!isRenaming && (
|
||||
<div className="absolute right-1 z-10 flex text-gray-300">
|
||||
<button
|
||||
className="min-w-[20px] p-1 text-neutral-400 hover:text-neutral-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsRenaming(true);
|
||||
setRenameValue(selectedConversation.name);
|
||||
}}
|
||||
>
|
||||
<IconPencil size={18} />
|
||||
</button>
|
||||
<button
|
||||
className="min-w-[20px] p-1 text-neutral-400 hover:text-neutral-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDeleting(true);
|
||||
}}
|
||||
>
|
||||
<IconTrash size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Conversation } from '@/types/chat';
|
||||
import { KeyValuePair } from '@/types/data';
|
||||
import { FC } from 'react';
|
||||
import { ConversationComponent } from './Conversation';
|
||||
|
||||
interface Props {
|
||||
loading: boolean;
|
||||
conversations: Conversation[];
|
||||
selectedConversation: Conversation;
|
||||
onSelectConversation: (conversation: Conversation) => void;
|
||||
onDeleteConversation: (conversation: Conversation) => void;
|
||||
onUpdateConversation: (
|
||||
conversation: Conversation,
|
||||
data: KeyValuePair,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const Conversations: FC<Props> = ({
|
||||
loading,
|
||||
conversations,
|
||||
selectedConversation,
|
||||
onSelectConversation,
|
||||
onDeleteConversation,
|
||||
onUpdateConversation,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
{conversations
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((conversation, index) => (
|
||||
<ConversationComponent
|
||||
key={index}
|
||||
selectedConversation={selectedConversation}
|
||||
conversation={conversation}
|
||||
loading={loading}
|
||||
onSelectConversation={onSelectConversation}
|
||||
onDeleteConversation={onDeleteConversation}
|
||||
onUpdateConversation={onUpdateConversation}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,232 @@
|
||||
import { PluginID, PluginKey } from '@/types/plugin';
|
||||
import { IconKey } from '@tabler/icons-react';
|
||||
import { FC, KeyboardEvent, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SidebarButton } from '../Sidebar/SidebarButton';
|
||||
|
||||
interface Props {
|
||||
pluginKeys: PluginKey[];
|
||||
onPluginKeyChange: (pluginKey: PluginKey) => void;
|
||||
onClearPluginKey: (pluginKey: PluginKey) => void;
|
||||
}
|
||||
|
||||
export const PluginKeys: FC<Props> = ({
|
||||
pluginKeys,
|
||||
onPluginKeyChange,
|
||||
onClearPluginKey,
|
||||
}) => {
|
||||
const { t } = useTranslation('sidebar');
|
||||
|
||||
const [isChanging, setIsChanging] = useState(false);
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleEnter = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
setIsChanging(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
setIsChanging(false);
|
||||
};
|
||||
|
||||
window.addEventListener('mousedown', handleMouseDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousedown', handleMouseDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarButton
|
||||
text={t('Plugin Keys')}
|
||||
icon={<IconKey size={18} />}
|
||||
onClick={() => setIsChanging(true)}
|
||||
/>
|
||||
|
||||
{isChanging && (
|
||||
<div
|
||||
className="z-100 fixed inset-0 flex items-center justify-center bg-black bg-opacity-50"
|
||||
onKeyDown={handleEnter}
|
||||
>
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div className="flex min-h-screen items-center justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div
|
||||
className="hidden sm:inline-block sm:h-screen sm:align-middle"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="dark:border-netural-400 inline-block max-h-[400px] transform overflow-hidden rounded-lg border border-gray-300 bg-white px-4 pt-5 pb-4 text-left align-bottom shadow-xl transition-all dark:bg-[#202123] sm:my-8 sm:max-h-[600px] sm:w-full sm:max-w-lg sm:p-6 sm:align-middle"
|
||||
role="dialog"
|
||||
>
|
||||
<div className="mb-10 text-4xl">Plugin Keys</div>
|
||||
|
||||
<div className="mt-6 rounded border p-4">
|
||||
<div className="text-xl font-bold">Google Search Plugin</div>
|
||||
<div className="mt-4 italic">
|
||||
Please enter your Google API Key and Google CSE ID to enable
|
||||
the Google Search Plugin.
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-sm font-bold text-black dark:text-neutral-200">
|
||||
Google API Key
|
||||
</div>
|
||||
<input
|
||||
className="mt-2 w-full rounded-lg border border-neutral-500 px-4 py-2 text-neutral-900 shadow focus:outline-none dark:border-neutral-800 dark:border-opacity-50 dark:bg-[#40414F] dark:text-neutral-100"
|
||||
type="password"
|
||||
value={
|
||||
pluginKeys
|
||||
.find((p) => p.pluginId === PluginID.GOOGLE_SEARCH)
|
||||
?.requiredKeys.find((k) => k.key === 'GOOGLE_API_KEY')
|
||||
?.value
|
||||
}
|
||||
onChange={(e) => {
|
||||
const pluginKey = pluginKeys.find(
|
||||
(p) => p.pluginId === PluginID.GOOGLE_SEARCH,
|
||||
);
|
||||
|
||||
if (pluginKey) {
|
||||
const requiredKey = pluginKey.requiredKeys.find(
|
||||
(k) => k.key === 'GOOGLE_API_KEY',
|
||||
);
|
||||
|
||||
if (requiredKey) {
|
||||
const updatedPluginKey = {
|
||||
...pluginKey,
|
||||
requiredKeys: pluginKey.requiredKeys.map((k) => {
|
||||
if (k.key === 'GOOGLE_API_KEY') {
|
||||
return {
|
||||
...k,
|
||||
value: e.target.value,
|
||||
};
|
||||
}
|
||||
|
||||
return k;
|
||||
}),
|
||||
};
|
||||
|
||||
onPluginKeyChange(updatedPluginKey);
|
||||
}
|
||||
} else {
|
||||
const newPluginKey: PluginKey = {
|
||||
pluginId: PluginID.GOOGLE_SEARCH,
|
||||
requiredKeys: [
|
||||
{
|
||||
key: 'GOOGLE_API_KEY',
|
||||
value: e.target.value,
|
||||
},
|
||||
{
|
||||
key: 'GOOGLE_CSE_ID',
|
||||
value: '',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
onPluginKeyChange(newPluginKey);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mt-6 text-sm font-bold text-black dark:text-neutral-200">
|
||||
Google CSE ID
|
||||
</div>
|
||||
<input
|
||||
className="mt-2 w-full rounded-lg border border-neutral-500 px-4 py-2 text-neutral-900 shadow focus:outline-none dark:border-neutral-800 dark:border-opacity-50 dark:bg-[#40414F] dark:text-neutral-100"
|
||||
type="password"
|
||||
value={
|
||||
pluginKeys
|
||||
.find((p) => p.pluginId === PluginID.GOOGLE_SEARCH)
|
||||
?.requiredKeys.find((k) => k.key === 'GOOGLE_CSE_ID')
|
||||
?.value
|
||||
}
|
||||
onChange={(e) => {
|
||||
const pluginKey = pluginKeys.find(
|
||||
(p) => p.pluginId === PluginID.GOOGLE_SEARCH,
|
||||
);
|
||||
|
||||
if (pluginKey) {
|
||||
const requiredKey = pluginKey.requiredKeys.find(
|
||||
(k) => k.key === 'GOOGLE_CSE_ID',
|
||||
);
|
||||
|
||||
if (requiredKey) {
|
||||
const updatedPluginKey = {
|
||||
...pluginKey,
|
||||
requiredKeys: pluginKey.requiredKeys.map((k) => {
|
||||
if (k.key === 'GOOGLE_CSE_ID') {
|
||||
return {
|
||||
...k,
|
||||
value: e.target.value,
|
||||
};
|
||||
}
|
||||
|
||||
return k;
|
||||
}),
|
||||
};
|
||||
|
||||
onPluginKeyChange(updatedPluginKey);
|
||||
}
|
||||
} else {
|
||||
const newPluginKey: PluginKey = {
|
||||
pluginId: PluginID.GOOGLE_SEARCH,
|
||||
requiredKeys: [
|
||||
{
|
||||
key: 'GOOGLE_API_KEY',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
key: 'GOOGLE_CSE_ID',
|
||||
value: e.target.value,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
onPluginKeyChange(newPluginKey);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
className="mt-6 w-full rounded-lg border border-neutral-500 px-4 py-2 text-neutral-900 shadow hover:bg-neutral-100 focus:outline-none dark:border-neutral-800 dark:border-opacity-50 dark:bg-white dark:text-black dark:hover:bg-neutral-300"
|
||||
onClick={() => {
|
||||
const pluginKey = pluginKeys.find(
|
||||
(p) => p.pluginId === PluginID.GOOGLE_SEARCH,
|
||||
);
|
||||
|
||||
if (pluginKey) {
|
||||
onClearPluginKey(pluginKey);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Clear Google Search Plugin Keys
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mt-6 w-full rounded-lg border border-neutral-500 px-4 py-2 text-neutral-900 shadow hover:bg-neutral-100 focus:outline-none dark:border-neutral-800 dark:border-opacity-50 dark:bg-white dark:text-black dark:hover:bg-neutral-300"
|
||||
onClick={() => setIsChanging(false)}
|
||||
>
|
||||
{t('Save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
import { Conversation } from '@/types/chat';
|
||||
import { KeyValuePair } from '@/types/data';
|
||||
import { Folder } from '@/types/folder';
|
||||
import {
|
||||
IconCaretDown,
|
||||
IconCaretRight,
|
||||
IconCheck,
|
||||
IconPencil,
|
||||
IconTrash,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { FC, KeyboardEvent, useEffect, useState } from 'react';
|
||||
import { ConversationComponent } from '../../Chatbar/Conversation';
|
||||
|
||||
interface Props {
|
||||
searchTerm: string;
|
||||
conversations: Conversation[];
|
||||
currentFolder: Folder;
|
||||
onDeleteFolder: (folder: string) => void;
|
||||
onUpdateFolder: (folder: string, name: string) => void;
|
||||
// conversation props
|
||||
selectedConversation: Conversation;
|
||||
loading: boolean;
|
||||
onSelectConversation: (conversation: Conversation) => void;
|
||||
onDeleteConversation: (conversation: Conversation) => void;
|
||||
onUpdateConversation: (
|
||||
conversation: Conversation,
|
||||
data: KeyValuePair,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const ChatFolder: FC<Props> = ({
|
||||
searchTerm,
|
||||
conversations,
|
||||
currentFolder,
|
||||
onDeleteFolder,
|
||||
onUpdateFolder,
|
||||
// conversation props
|
||||
selectedConversation,
|
||||
loading,
|
||||
onSelectConversation,
|
||||
onDeleteConversation,
|
||||
onUpdateConversation,
|
||||
}) => {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const handleEnterDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleRename();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRename = () => {
|
||||
onUpdateFolder(currentFolder.id, renameValue);
|
||||
setRenameValue('');
|
||||
setIsRenaming(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: any, folder: Folder) => {
|
||||
if (e.dataTransfer) {
|
||||
setIsOpen(true);
|
||||
|
||||
const conversation = JSON.parse(e.dataTransfer.getData('conversation'));
|
||||
onUpdateConversation(conversation, { key: 'folderId', value: folder.id });
|
||||
|
||||
e.target.style.background = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
const allowDrop = (e: any) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const highlightDrop = (e: any) => {
|
||||
e.target.style.background = '#343541';
|
||||
};
|
||||
|
||||
const removeHighlight = (e: any) => {
|
||||
e.target.style.background = 'none';
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isRenaming) {
|
||||
setIsDeleting(false);
|
||||
} else if (isDeleting) {
|
||||
setIsRenaming(false);
|
||||
}
|
||||
}, [isRenaming, isDeleting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchTerm) {
|
||||
setIsOpen(true);
|
||||
} else {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [searchTerm]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex items-center">
|
||||
{isRenaming ? (
|
||||
<div className="flex w-full items-center gap-3 bg-[#343541]/90 p-3 rounded-lg">
|
||||
{isOpen ? (
|
||||
<IconCaretDown size={18} />
|
||||
) : (
|
||||
<IconCaretRight size={18} />
|
||||
)}
|
||||
|
||||
<input
|
||||
className="mr-12 flex-1 overflow-hidden overflow-ellipsis border-neutral-400 bg-transparent text-left text-[12.5px] leading-3 text-white outline-none focus:border-neutral-100"
|
||||
type="text"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={handleEnterDown}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className={`flex w-full cursor-pointer items-center gap-3 rounded-lg p-3 text-sm transition-colors duration-200 hover:bg-[#343541]/90`}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
onDrop={(e) => handleDrop(e, currentFolder)}
|
||||
onDragOver={allowDrop}
|
||||
onDragEnter={highlightDrop}
|
||||
onDragLeave={removeHighlight}
|
||||
>
|
||||
{isOpen ? (
|
||||
<IconCaretDown size={18} />
|
||||
) : (
|
||||
<IconCaretRight size={18} />
|
||||
)}
|
||||
|
||||
<div className="relative max-h-5 flex-1 overflow-hidden text-ellipsis whitespace-nowrap break-all text-left text-[12.5px] leading-3">
|
||||
{currentFolder.name}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(isDeleting || isRenaming) && (
|
||||
<div className="absolute right-1 z-10 flex text-gray-300">
|
||||
<button
|
||||
className="min-w-[20px] p-1 text-neutral-400 hover:text-neutral-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (isDeleting) {
|
||||
onDeleteFolder(currentFolder.id);
|
||||
} else if (isRenaming) {
|
||||
handleRename();
|
||||
}
|
||||
|
||||
setIsDeleting(false);
|
||||
setIsRenaming(false);
|
||||
}}
|
||||
>
|
||||
<IconCheck size={18} />
|
||||
</button>
|
||||
<button
|
||||
className="min-w-[20px] p-1 text-neutral-400 hover:text-neutral-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDeleting(false);
|
||||
setIsRenaming(false);
|
||||
}}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isDeleting && !isRenaming && (
|
||||
<div className="absolute right-1 z-10 flex text-gray-300">
|
||||
<button
|
||||
className="min-w-[20px] p-1 text-neutral-400 hover:text-neutral-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsRenaming(true);
|
||||
setRenameValue(currentFolder.name);
|
||||
}}
|
||||
>
|
||||
<IconPencil size={18} />
|
||||
</button>
|
||||
<button
|
||||
className="min-w-[20px] p-1 text-neutral-400 hover:text-neutral-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDeleting(true);
|
||||
}}
|
||||
>
|
||||
<IconTrash size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOpen
|
||||
? conversations.map((conversation, index) => {
|
||||
if (conversation.folderId === currentFolder.id) {
|
||||
return (
|
||||
<div key={index} className="ml-5 gap-2 border-l pl-2">
|
||||
<ConversationComponent
|
||||
selectedConversation={selectedConversation}
|
||||
conversation={conversation}
|
||||
loading={loading}
|
||||
onSelectConversation={onSelectConversation}
|
||||
onDeleteConversation={onDeleteConversation}
|
||||
onUpdateConversation={onUpdateConversation}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Conversation } from '@/types/chat';
|
||||
import { KeyValuePair } from '@/types/data';
|
||||
import { Folder } from '@/types/folder';
|
||||
import { FC } from 'react';
|
||||
import { ChatFolder } from './ChatFolder';
|
||||
|
||||
interface Props {
|
||||
searchTerm: string;
|
||||
conversations: Conversation[];
|
||||
folders: Folder[];
|
||||
onDeleteFolder: (folder: string) => void;
|
||||
onUpdateFolder: (folder: string, name: string) => void;
|
||||
// conversation props
|
||||
selectedConversation: Conversation;
|
||||
loading: boolean;
|
||||
onSelectConversation: (conversation: Conversation) => void;
|
||||
onDeleteConversation: (conversation: Conversation) => void;
|
||||
onUpdateConversation: (
|
||||
conversation: Conversation,
|
||||
data: KeyValuePair,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const ChatFolders: FC<Props> = ({
|
||||
searchTerm,
|
||||
conversations,
|
||||
folders,
|
||||
onDeleteFolder,
|
||||
onUpdateFolder,
|
||||
// conversation props
|
||||
selectedConversation,
|
||||
loading,
|
||||
onSelectConversation,
|
||||
onDeleteConversation,
|
||||
onUpdateConversation,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex w-full flex-col pt-2">
|
||||
{folders.map((folder, index) => (
|
||||
<ChatFolder
|
||||
key={index}
|
||||
searchTerm={searchTerm}
|
||||
conversations={conversations.filter((c) => c.folderId)}
|
||||
currentFolder={folder}
|
||||
onDeleteFolder={onDeleteFolder}
|
||||
onUpdateFolder={onUpdateFolder}
|
||||
// conversation props
|
||||
selectedConversation={selectedConversation}
|
||||
loading={loading}
|
||||
onSelectConversation={onSelectConversation}
|
||||
onDeleteConversation={onDeleteConversation}
|
||||
onUpdateConversation={onUpdateConversation}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,212 @@
|
||||
import { PromptComponent } from '@/components/Promptbar/Prompt';
|
||||
import { Folder } from '@/types/folder';
|
||||
import { Prompt } from '@/types/prompt';
|
||||
import {
|
||||
IconCaretDown,
|
||||
IconCaretRight,
|
||||
IconCheck,
|
||||
IconPencil,
|
||||
IconTrash,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { FC, KeyboardEvent, useEffect, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
searchTerm: string;
|
||||
prompts: Prompt[];
|
||||
currentFolder: Folder;
|
||||
onDeleteFolder: (folder: string) => void;
|
||||
onUpdateFolder: (folder: string, name: string) => void;
|
||||
// prompt props
|
||||
onDeletePrompt: (prompt: Prompt) => void;
|
||||
onUpdatePrompt: (prompt: Prompt) => void;
|
||||
}
|
||||
|
||||
export const PromptFolder: FC<Props> = ({
|
||||
searchTerm,
|
||||
prompts,
|
||||
currentFolder,
|
||||
onDeleteFolder,
|
||||
onUpdateFolder,
|
||||
// prompt props
|
||||
onDeletePrompt,
|
||||
onUpdatePrompt,
|
||||
}) => {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const handleEnterDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleRename();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRename = () => {
|
||||
onUpdateFolder(currentFolder.id, renameValue);
|
||||
setRenameValue('');
|
||||
setIsRenaming(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: any, folder: Folder) => {
|
||||
if (e.dataTransfer) {
|
||||
setIsOpen(true);
|
||||
|
||||
const prompt = JSON.parse(e.dataTransfer.getData('prompt'));
|
||||
|
||||
const updatedPrompt = {
|
||||
...prompt,
|
||||
folderId: folder.id,
|
||||
};
|
||||
|
||||
onUpdatePrompt(updatedPrompt);
|
||||
|
||||
e.target.style.background = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
const allowDrop = (e: any) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const highlightDrop = (e: any) => {
|
||||
e.target.style.background = '#343541';
|
||||
};
|
||||
|
||||
const removeHighlight = (e: any) => {
|
||||
e.target.style.background = 'none';
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isRenaming) {
|
||||
setIsDeleting(false);
|
||||
} else if (isDeleting) {
|
||||
setIsRenaming(false);
|
||||
}
|
||||
}, [isRenaming, isDeleting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchTerm) {
|
||||
setIsOpen(true);
|
||||
} else {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [searchTerm]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex items-center">
|
||||
{isRenaming ? (
|
||||
<div className="flex w-full items-center gap-3 bg-[#343541]/90 p-3">
|
||||
{isOpen ? (
|
||||
<IconCaretDown size={18} />
|
||||
) : (
|
||||
<IconCaretRight size={18} />
|
||||
)}
|
||||
<input
|
||||
className="mr-12 flex-1 overflow-hidden overflow-ellipsis border-neutral-400 bg-transparent text-left text-[12.5px] leading-3 text-white outline-none focus:border-neutral-100"
|
||||
type="text"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={handleEnterDown}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className={`flex w-full cursor-pointer items-center gap-3 rounded-lg p-3 text-sm transition-colors duration-200 hover:bg-[#343541]/90`}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
onDrop={(e) => handleDrop(e, currentFolder)}
|
||||
onDragOver={allowDrop}
|
||||
onDragEnter={highlightDrop}
|
||||
onDragLeave={removeHighlight}
|
||||
>
|
||||
{isOpen ? (
|
||||
<IconCaretDown size={18} />
|
||||
) : (
|
||||
<IconCaretRight size={18} />
|
||||
)}
|
||||
|
||||
<div className="relative max-h-5 flex-1 overflow-hidden text-ellipsis whitespace-nowrap break-all text-left text-[12.5px] leading-3">
|
||||
{currentFolder.name}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(isDeleting || isRenaming) && (
|
||||
<div className="absolute right-1 z-10 flex text-gray-300">
|
||||
<button
|
||||
className="min-w-[20px] p-1 text-neutral-400 hover:text-neutral-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (isDeleting) {
|
||||
onDeleteFolder(currentFolder.id);
|
||||
} else if (isRenaming) {
|
||||
handleRename();
|
||||
}
|
||||
|
||||
setIsDeleting(false);
|
||||
setIsRenaming(false);
|
||||
}}
|
||||
>
|
||||
<IconCheck size={18} />
|
||||
</button>
|
||||
<button
|
||||
className="min-w-[20px] p-1 text-neutral-400 hover:text-neutral-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDeleting(false);
|
||||
setIsRenaming(false);
|
||||
}}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isDeleting && !isRenaming && (
|
||||
<div className="absolute right-1 z-10 flex text-gray-300">
|
||||
<button
|
||||
className="min-w-[20px] p-1 text-neutral-400 hover:text-neutral-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsRenaming(true);
|
||||
setRenameValue(currentFolder.name);
|
||||
}}
|
||||
>
|
||||
<IconPencil size={18} />
|
||||
</button>
|
||||
<button
|
||||
className="min-w-[20px] p-1 text-neutral-400 hover:text-neutral-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDeleting(true);
|
||||
}}
|
||||
>
|
||||
<IconTrash size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOpen
|
||||
? prompts.map((prompt, index) => {
|
||||
if (prompt.folderId === currentFolder.id) {
|
||||
return (
|
||||
<div key={index} className="ml-5 gap-2 border-l pl-2">
|
||||
<PromptComponent
|
||||
prompt={prompt}
|
||||
onDeletePrompt={onDeletePrompt}
|
||||
onUpdatePrompt={onUpdatePrompt}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Folder } from '@/types/folder';
|
||||
import { Prompt } from '@/types/prompt';
|
||||
import { FC } from 'react';
|
||||
import { PromptFolder } from './PromptFolder';
|
||||
|
||||
interface Props {
|
||||
searchTerm: string;
|
||||
prompts: Prompt[];
|
||||
folders: Folder[];
|
||||
onDeleteFolder: (folder: string) => void;
|
||||
onUpdateFolder: (folder: string, name: string) => void;
|
||||
// prompt props
|
||||
onDeletePrompt: (prompt: Prompt) => void;
|
||||
onUpdatePrompt: (prompt: Prompt) => void;
|
||||
}
|
||||
|
||||
export const PromptFolders: FC<Props> = ({
|
||||
searchTerm,
|
||||
prompts,
|
||||
folders,
|
||||
onDeleteFolder,
|
||||
onUpdateFolder,
|
||||
// prompt props
|
||||
onDeletePrompt,
|
||||
onUpdatePrompt,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex w-full flex-col pt-2">
|
||||
{folders.map((folder, index) => (
|
||||
<PromptFolder
|
||||
key={index}
|
||||
searchTerm={searchTerm}
|
||||
prompts={prompts.filter((p) => p.folderId)}
|
||||
currentFolder={folder}
|
||||
onDeleteFolder={onDeleteFolder}
|
||||
onUpdateFolder={onUpdateFolder}
|
||||
// prompt props
|
||||
onDeletePrompt={onDeletePrompt}
|
||||
onUpdatePrompt={onUpdatePrompt}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { FC } from 'react';
|
||||
|
||||
interface Props {
|
||||
size?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Spinner: FC<Props> = ({ size = '1em', className="" }) => {
|
||||
return (
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={`animate-spin ${className}`}
|
||||
height={size}
|
||||
width={size}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<line x1="12" y1="2" x2="12" y2="6"></line>
|
||||
<line x1="12" y1="18" x2="12" y2="22"></line>
|
||||
<line x1="4.93" y1="4.93" x2="7.76" y2="7.76"></line>
|
||||
<line x1="16.24" y1="16.24" x2="19.07" y2="19.07"></line>
|
||||
<line x1="2" y1="12" x2="6" y2="12"></line>
|
||||
<line x1="18" y1="12" x2="22" y2="12"></line>
|
||||
<line x1="4.93" y1="19.07" x2="7.76" y2="16.24"></line>
|
||||
<line x1="16.24" y1="7.76" x2="19.07" y2="4.93"></line>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -1,41 +1,96 @@
|
||||
import { FC, useState } from "react";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneDark, oneLight } from "react-syntax-highlighter/dist/cjs/styles/prism";
|
||||
import {
|
||||
generateRandomString,
|
||||
programmingLanguages,
|
||||
} from '@/utils/app/codeblock';
|
||||
import { IconCheck, IconClipboard, IconDownload } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { FC, memo, useState } from 'react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
|
||||
|
||||
interface Props {
|
||||
language: string;
|
||||
value: string;
|
||||
lightMode: "light" | "dark";
|
||||
}
|
||||
|
||||
export const CodeBlock: FC<Props> = ({ language, value, lightMode }) => {
|
||||
const [buttonText, setButtonText] = useState("Copy code");
|
||||
export const CodeBlock: FC<Props> = memo(({ language, value }) => {
|
||||
const { t } = useTranslation('markdown');
|
||||
const [isCopied, setIsCopied] = useState<Boolean>(false);
|
||||
|
||||
const copyToClipboard = () => {
|
||||
if (!navigator.clipboard || !navigator.clipboard.writeText) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(value).then(() => {
|
||||
setButtonText("Copied!");
|
||||
setIsCopied(true);
|
||||
|
||||
setTimeout(() => {
|
||||
setButtonText("Copy code");
|
||||
setIsCopied(false);
|
||||
}, 2000);
|
||||
});
|
||||
};
|
||||
const downloadAsFile = () => {
|
||||
const fileExtension = programmingLanguages[language] || '.file';
|
||||
const suggestedFileName = `file-${generateRandomString(
|
||||
3,
|
||||
true,
|
||||
)}${fileExtension}`;
|
||||
const fileName = window.prompt(
|
||||
t('Enter file name') || '',
|
||||
suggestedFileName,
|
||||
);
|
||||
|
||||
if (!fileName) {
|
||||
// user pressed cancel on prompt
|
||||
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);
|
||||
};
|
||||
return (
|
||||
<div className="relative text-[16px] pt-2">
|
||||
<div className="codeblock relative font-sans text-[16px]">
|
||||
<div className="flex items-center justify-between py-1.5 px-4">
|
||||
<span className="text-xs lowercase text-white">{language}</span>
|
||||
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
className="flex gap-1.5 items-center rounded bg-none p-1 text-xs text-white"
|
||||
onClick={copyToClipboard}
|
||||
>
|
||||
{isCopied ? (
|
||||
<IconCheck size={18} />
|
||||
) : (
|
||||
<IconClipboard size={18} />
|
||||
)}
|
||||
{isCopied ? t('Copied!') : t('Copy code')}
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center rounded bg-none p-1 text-xs text-white"
|
||||
onClick={downloadAsFile}
|
||||
>
|
||||
<IconDownload size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={lightMode === "light" ? oneLight : oneDark}
|
||||
style={oneDark}
|
||||
customStyle={{ margin: 0 }}
|
||||
>
|
||||
{value}
|
||||
</SyntaxHighlighter>
|
||||
|
||||
<button
|
||||
className="absolute top-[-8px] right-[0px] text-white bg-none py-0.5 px-2 rounded focus:outline-none hover:bg-blue-700 text-xs"
|
||||
onClick={copyToClipboard}
|
||||
>
|
||||
{buttonText}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
CodeBlock.displayName = 'CodeBlock';
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { FC, memo } from 'react';
|
||||
import ReactMarkdown, { Options } from 'react-markdown';
|
||||
|
||||
export const MemoizedReactMarkdown: FC<Options> = memo(ReactMarkdown);
|
||||
@@ -1,23 +1,28 @@
|
||||
import { Conversation } from "@/types";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import { FC } from "react";
|
||||
import { Conversation } from '@/types/chat';
|
||||
import { IconPlus } from '@tabler/icons-react';
|
||||
import { FC } from 'react';
|
||||
|
||||
interface Props {
|
||||
selectedConversation: Conversation;
|
||||
onNewConversation: () => void;
|
||||
}
|
||||
|
||||
export const Navbar: FC<Props> = ({ selectedConversation, onNewConversation }) => {
|
||||
export const Navbar: FC<Props> = ({
|
||||
selectedConversation,
|
||||
onNewConversation,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex justify-between bg-[#202123] py-3 px-4 w-full">
|
||||
<nav className="flex w-full justify-between bg-[#202123] py-3 px-4">
|
||||
<div className="mr-4"></div>
|
||||
|
||||
<div className="max-w-[240px] whitespace-nowrap overflow-hidden text-ellipsis">{selectedConversation.name}</div>
|
||||
<div className="max-w-[240px] overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{selectedConversation.name}
|
||||
</div>
|
||||
|
||||
<IconPlus
|
||||
className="cursor-pointer hover:text-neutral-400"
|
||||
className="cursor-pointer hover:text-neutral-400 mr-8"
|
||||
onClick={onNewConversation}
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Prompt } from '@/types/prompt';
|
||||
import {
|
||||
IconBulbFilled,
|
||||
IconCheck,
|
||||
IconTrash,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { DragEvent, FC, useEffect, useState } from 'react';
|
||||
import { PromptModal } from './PromptModal';
|
||||
|
||||
interface Props {
|
||||
prompt: Prompt;
|
||||
onUpdatePrompt: (prompt: Prompt) => void;
|
||||
onDeletePrompt: (prompt: Prompt) => void;
|
||||
}
|
||||
|
||||
export const PromptComponent: FC<Props> = ({
|
||||
prompt,
|
||||
onUpdatePrompt,
|
||||
onDeletePrompt,
|
||||
}) => {
|
||||
const [showModal, setShowModal] = useState<boolean>(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
|
||||
const handleDragStart = (e: DragEvent<HTMLButtonElement>, prompt: Prompt) => {
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.setData('prompt', JSON.stringify(prompt));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isRenaming) {
|
||||
setIsDeleting(false);
|
||||
} else if (isDeleting) {
|
||||
setIsRenaming(false);
|
||||
}
|
||||
}, [isRenaming, isDeleting]);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center">
|
||||
<button
|
||||
className="flex w-full cursor-pointer items-center gap-3 rounded-lg p-3 text-sm transition-colors duration-200 hover:bg-[#343541]/90"
|
||||
draggable="true"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowModal(true);
|
||||
}}
|
||||
onDragStart={(e) => handleDragStart(e, prompt)}
|
||||
onMouseLeave={() => {
|
||||
setIsDeleting(false);
|
||||
setIsRenaming(false);
|
||||
setRenameValue('');
|
||||
}}
|
||||
>
|
||||
<IconBulbFilled size={18} />
|
||||
|
||||
<div className="relative max-h-5 flex-1 overflow-hidden text-ellipsis whitespace-nowrap break-all pr-4 text-left text-[12.5px] leading-3">
|
||||
{prompt.name}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{(isDeleting || isRenaming) && (
|
||||
<div className="absolute right-1 z-10 flex text-gray-300">
|
||||
<button
|
||||
className="min-w-[20px] p-1 text-neutral-400 hover:text-neutral-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (isDeleting) {
|
||||
onDeletePrompt(prompt);
|
||||
}
|
||||
|
||||
setIsDeleting(false);
|
||||
}}
|
||||
>
|
||||
<IconCheck size={18} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="min-w-[20px] p-1 text-neutral-400 hover:text-neutral-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDeleting(false);
|
||||
}}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isDeleting && !isRenaming && (
|
||||
<div className="absolute right-1 z-10 flex text-gray-300">
|
||||
<button
|
||||
className="min-w-[20px] p-1 text-neutral-400 hover:text-neutral-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDeleting(true);
|
||||
}}
|
||||
>
|
||||
<IconTrash size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showModal && (
|
||||
<PromptModal
|
||||
prompt={prompt}
|
||||
onClose={() => setShowModal(false)}
|
||||
onUpdatePrompt={onUpdatePrompt}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Prompt } from '@/types/prompt';
|
||||
import { FC, KeyboardEvent, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
interface Props {
|
||||
prompt: Prompt;
|
||||
onClose: () => void;
|
||||
onUpdatePrompt: (prompt: Prompt) => void;
|
||||
}
|
||||
|
||||
export const PromptModal: FC<Props> = ({ prompt, onClose, onUpdatePrompt }) => {
|
||||
const { t } = useTranslation('promptbar');
|
||||
const [name, setName] = useState(prompt.name);
|
||||
const [description, setDescription] = useState(prompt.description);
|
||||
const [content, setContent] = useState(prompt.content);
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleEnter = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
onUpdatePrompt({ ...prompt, name, description, content: content.trim() });
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
onClose();
|
||||
};
|
||||
|
||||
window.addEventListener('mousedown', handleMouseDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousedown', handleMouseDown);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
nameInputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-100"
|
||||
onKeyDown={handleEnter}
|
||||
>
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div
|
||||
className="hidden sm:inline-block sm:h-screen sm:align-middle"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="dark:border-netural-400 inline-block max-h-[400px] transform overflow-hidden rounded-lg border border-gray-300 bg-white px-4 pt-5 pb-4 text-left align-bottom shadow-xl transition-all dark:bg-[#202123] sm:my-8 sm:max-h-[600px] sm:w-full sm:max-w-lg sm:p-6 sm:align-middle"
|
||||
role="dialog"
|
||||
>
|
||||
<div className="text-sm font-bold text-black dark:text-neutral-200">
|
||||
{t('Name')}
|
||||
</div>
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
className="mt-2 w-full rounded-lg border border-neutral-500 px-4 py-2 text-neutral-900 shadow focus:outline-none dark:border-neutral-800 dark:border-opacity-50 dark:bg-[#40414F] dark:text-neutral-100"
|
||||
placeholder={t('A name for your prompt.') || ''}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="mt-6 text-sm font-bold text-black dark:text-neutral-200">
|
||||
{t('Description')}
|
||||
</div>
|
||||
<textarea
|
||||
className="mt-2 w-full rounded-lg border border-neutral-500 px-4 py-2 text-neutral-900 shadow focus:outline-none dark:border-neutral-800 dark:border-opacity-50 dark:bg-[#40414F] dark:text-neutral-100"
|
||||
style={{ resize: 'none' }}
|
||||
placeholder={t('A description for your prompt.') || ''}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<div className="mt-6 text-sm font-bold text-black dark:text-neutral-200">
|
||||
{t('Prompt')}
|
||||
</div>
|
||||
<textarea
|
||||
className="mt-2 w-full rounded-lg border border-neutral-500 px-4 py-2 text-neutral-900 shadow focus:outline-none dark:border-neutral-800 dark:border-opacity-50 dark:bg-[#40414F] dark:text-neutral-100"
|
||||
style={{ resize: 'none' }}
|
||||
placeholder={
|
||||
t(
|
||||
'Prompt content. Use {{}} to denote a variable. Ex: {{name}} is a {{adjective}} {{noun}}',
|
||||
) || ''
|
||||
}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
rows={10}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-4 py-2 mt-6 border rounded-lg shadow border-neutral-500 text-neutral-900 hover:bg-neutral-100 focus:outline-none dark:border-neutral-800 dark:border-opacity-50 dark:bg-white dark:text-black dark:hover:bg-neutral-300"
|
||||
onClick={() => {
|
||||
const updatedPrompt = {
|
||||
...prompt,
|
||||
name,
|
||||
description,
|
||||
content: content.trim(),
|
||||
};
|
||||
|
||||
onUpdatePrompt(updatedPrompt);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{t('Save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
import { Folder } from '@/types/folder';
|
||||
import { Prompt } from '@/types/prompt';
|
||||
import {
|
||||
IconFolderPlus,
|
||||
IconMistOff,
|
||||
IconPlus,
|
||||
} from '@tabler/icons-react';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PromptFolders } from '../Folders/Prompt/PromptFolders';
|
||||
import { Search } from '../Sidebar/Search';
|
||||
import { PromptbarSettings } from './PromptbarSettings';
|
||||
import { Prompts } from './Prompts';
|
||||
|
||||
interface Props {
|
||||
prompts: Prompt[];
|
||||
folders: Folder[];
|
||||
onCreateFolder: (name: string) => void;
|
||||
onDeleteFolder: (folderId: string) => void;
|
||||
onUpdateFolder: (folderId: string, name: string) => void;
|
||||
onCreatePrompt: () => void;
|
||||
onUpdatePrompt: (prompt: Prompt) => void;
|
||||
onDeletePrompt: (prompt: Prompt) => void;
|
||||
}
|
||||
|
||||
export const Promptbar: FC<Props> = ({
|
||||
folders,
|
||||
prompts,
|
||||
onCreateFolder,
|
||||
onDeleteFolder,
|
||||
onUpdateFolder,
|
||||
onCreatePrompt,
|
||||
onUpdatePrompt,
|
||||
onDeletePrompt,
|
||||
}) => {
|
||||
const { t } = useTranslation('promptbar');
|
||||
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||
const [filteredPrompts, setFilteredPrompts] = useState<Prompt[]>(prompts);
|
||||
|
||||
const handleUpdatePrompt = (prompt: Prompt) => {
|
||||
onUpdatePrompt(prompt);
|
||||
setSearchTerm('');
|
||||
};
|
||||
|
||||
const handleDeletePrompt = (prompt: Prompt) => {
|
||||
onDeletePrompt(prompt);
|
||||
setSearchTerm('');
|
||||
};
|
||||
|
||||
const handleDrop = (e: any) => {
|
||||
if (e.dataTransfer) {
|
||||
const prompt = JSON.parse(e.dataTransfer.getData('prompt'));
|
||||
|
||||
const updatedPrompt = {
|
||||
...prompt,
|
||||
folderId: e.target.dataset.folderId,
|
||||
};
|
||||
|
||||
onUpdatePrompt(updatedPrompt);
|
||||
|
||||
e.target.style.background = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
const allowDrop = (e: any) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const highlightDrop = (e: any) => {
|
||||
e.target.style.background = '#343541';
|
||||
};
|
||||
|
||||
const removeHighlight = (e: any) => {
|
||||
e.target.style.background = 'none';
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (searchTerm) {
|
||||
setFilteredPrompts(
|
||||
prompts.filter((prompt) => {
|
||||
const searchable =
|
||||
prompt.name.toLowerCase() +
|
||||
' ' +
|
||||
prompt.description.toLowerCase() +
|
||||
' ' +
|
||||
prompt.content.toLowerCase();
|
||||
return searchable.includes(searchTerm.toLowerCase());
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
setFilteredPrompts(prompts);
|
||||
}
|
||||
}, [searchTerm, prompts]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed top-0 right-0 z-50 flex h-full w-[260px] flex-none flex-col space-y-2 bg-[#202123] p-2 text-[14px] transition-all sm:relative sm:top-0`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
className="text-sidebar flex w-[190px] flex-shrink-0 cursor-pointer select-none items-center gap-3 rounded-md border border-white/20 p-3 text-white transition-colors duration-200 hover:bg-gray-500/10"
|
||||
onClick={() => {
|
||||
onCreatePrompt();
|
||||
setSearchTerm('');
|
||||
}}
|
||||
>
|
||||
<IconPlus size={16} />
|
||||
{t('New prompt')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="flex items-center flex-shrink-0 gap-3 p-3 ml-2 text-sm text-white transition-colors duration-200 border rounded-md cursor-pointer border-white/20 hover:bg-gray-500/10"
|
||||
onClick={() => onCreateFolder(t('New folder'))}
|
||||
>
|
||||
<IconFolderPlus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{prompts.length > 1 && (
|
||||
<Search
|
||||
placeholder={t('Search prompts...') || ''}
|
||||
searchTerm={searchTerm}
|
||||
onSearch={setSearchTerm}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex-grow overflow-auto">
|
||||
{folders.length > 0 && (
|
||||
<div className="flex pb-2 border-b border-white/20">
|
||||
<PromptFolders
|
||||
searchTerm={searchTerm}
|
||||
prompts={filteredPrompts}
|
||||
folders={folders}
|
||||
onUpdateFolder={onUpdateFolder}
|
||||
onDeleteFolder={onDeleteFolder}
|
||||
// prompt props
|
||||
onDeletePrompt={handleDeletePrompt}
|
||||
onUpdatePrompt={handleUpdatePrompt}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{prompts.length > 0 ? (
|
||||
<div
|
||||
className="pt-2"
|
||||
onDrop={(e) => handleDrop(e)}
|
||||
onDragOver={allowDrop}
|
||||
onDragEnter={highlightDrop}
|
||||
onDragLeave={removeHighlight}
|
||||
>
|
||||
<Prompts
|
||||
prompts={filteredPrompts.filter((prompt) => !prompt.folderId)}
|
||||
onUpdatePrompt={handleUpdatePrompt}
|
||||
onDeletePrompt={handleDeletePrompt}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-8 text-center text-white opacity-50 select-none">
|
||||
<IconMistOff className="mx-auto mb-3" />
|
||||
<span className="text-[14px] leading-normal">
|
||||
{t('No prompts.')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PromptbarSettings />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { FC } from "react";
|
||||
|
||||
interface Props {}
|
||||
|
||||
export const PromptbarSettings: FC<Props> = () => {
|
||||
return <div></div>;
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Prompt } from '@/types/prompt';
|
||||
import { FC } from 'react';
|
||||
import { PromptComponent } from './Prompt';
|
||||
|
||||
interface Props {
|
||||
prompts: Prompt[];
|
||||
onUpdatePrompt: (prompt: Prompt) => void;
|
||||
onDeletePrompt: (prompt: Prompt) => void;
|
||||
}
|
||||
|
||||
export const Prompts: FC<Props> = ({
|
||||
prompts,
|
||||
onUpdatePrompt,
|
||||
onDeletePrompt,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
{prompts
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((prompt, index) => (
|
||||
<PromptComponent
|
||||
key={index}
|
||||
prompt={prompt}
|
||||
onUpdatePrompt={onUpdatePrompt}
|
||||
onDeletePrompt={onDeletePrompt}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { SupportedExportFormats } from '@/types/export';
|
||||
import { IconFileImport } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { FC } from 'react';
|
||||
import { SidebarButton } from '../Sidebar/SidebarButton';
|
||||
|
||||
interface Props {
|
||||
onImport: (data: SupportedExportFormats) => void;
|
||||
}
|
||||
|
||||
export const Import: FC<Props> = ({ onImport }) => {
|
||||
const { t } = useTranslation('sidebar');
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
id="import-file"
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={(e) => {
|
||||
if (!e.target.files?.length) return;
|
||||
|
||||
const file = e.target.files[0];
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
let json = JSON.parse(e.target?.result as string);
|
||||
onImport(json);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}}
|
||||
/>
|
||||
|
||||
<SidebarButton
|
||||
text={t('Import data')}
|
||||
icon={<IconFileImport size={18} />}
|
||||
onClick={() => {
|
||||
const importFile = document.querySelector(
|
||||
'#import-file',
|
||||
) as HTMLInputElement;
|
||||
if (importFile) {
|
||||
importFile.click();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IconCheck, IconKey, IconX } from "@tabler/icons-react";
|
||||
import { FC, KeyboardEvent, useState } from "react";
|
||||
import { SidebarButton } from "./SidebarButton";
|
||||
import { IconCheck, IconKey, IconX } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { FC, KeyboardEvent, useEffect, useRef, useState } from 'react';
|
||||
import { SidebarButton } from '../Sidebar/SidebarButton';
|
||||
|
||||
interface Props {
|
||||
apiKey: string;
|
||||
@@ -8,11 +9,13 @@ interface Props {
|
||||
}
|
||||
|
||||
export const Key: FC<Props> = ({ apiKey, onApiKeyChange }) => {
|
||||
const { t } = useTranslation('sidebar');
|
||||
const [isChanging, setIsChanging] = useState(false);
|
||||
const [newKey, setNewKey] = useState(apiKey);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleEnterDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleUpdateKey(newKey);
|
||||
}
|
||||
@@ -22,17 +25,25 @@ export const Key: FC<Props> = ({ apiKey, onApiKeyChange }) => {
|
||||
onApiKeyChange(newKey.trim());
|
||||
setIsChanging(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isChanging) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [isChanging]);
|
||||
|
||||
return isChanging ? (
|
||||
<div className="flex transition-colors duration:200 hover:bg-gray-500/10 py-3 px-3 rounded-md cursor-pointer w-full items-center">
|
||||
<IconKey size={16} />
|
||||
<div className="duration:200 flex w-full cursor-pointer items-center rounded-md py-3 px-3 transition-colors hover:bg-gray-500/10">
|
||||
<IconKey size={18} />
|
||||
|
||||
<input
|
||||
className="ml-2 flex-1 h-[20px] bg-transparent border-b border-neutral-400 focus:border-neutral-100 text-left overflow-hidden overflow-ellipsis pr-1 outline-none text-white"
|
||||
ref={inputRef}
|
||||
className="ml-2 h-[20px] flex-1 overflow-hidden overflow-ellipsis border-b border-neutral-400 bg-transparent pr-1 text-[12.5px] leading-3 text-left text-white outline-none focus:border-neutral-100"
|
||||
type="password"
|
||||
value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)}
|
||||
onKeyDown={handleEnterDown}
|
||||
placeholder={t('API Key') || 'API Key'}
|
||||
/>
|
||||
|
||||
<div className="flex w-[40px]">
|
||||
@@ -58,8 +69,8 @@ export const Key: FC<Props> = ({ apiKey, onApiKeyChange }) => {
|
||||
</div>
|
||||
) : (
|
||||
<SidebarButton
|
||||
text="OpenAI API Key"
|
||||
icon={<IconKey size={16} />}
|
||||
text={t('OpenAI API Key')}
|
||||
icon={<IconKey size={18} />}
|
||||
onClick={() => setIsChanging(true)}
|
||||
/>
|
||||
);
|
||||
@@ -1,124 +0,0 @@
|
||||
import { Conversation } from "@/types";
|
||||
import { IconCheck, IconMessage, IconPencil, IconTrash, IconX } from "@tabler/icons-react";
|
||||
import { FC, KeyboardEvent, useEffect, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
loading: boolean;
|
||||
conversations: Conversation[];
|
||||
selectedConversation: Conversation;
|
||||
onSelectConversation: (conversation: Conversation) => void;
|
||||
onDeleteConversation: (conversation: Conversation) => void;
|
||||
onRenameConversation: (conversation: Conversation, name: string) => void;
|
||||
}
|
||||
|
||||
export const Conversations: FC<Props> = ({ loading, conversations, selectedConversation, onSelectConversation, onDeleteConversation, onRenameConversation }) => {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
|
||||
const handleEnterDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleRename(selectedConversation);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRename = (conversation: Conversation) => {
|
||||
onRenameConversation(conversation, renameValue);
|
||||
setRenameValue("");
|
||||
setIsRenaming(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isRenaming) {
|
||||
setIsDeleting(false);
|
||||
} else if (isDeleting) {
|
||||
setIsRenaming(false);
|
||||
}
|
||||
}, [isRenaming, isDeleting]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col-reverse gap-1 w-full pt-2">
|
||||
{conversations.map((conversation, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className={`flex gap-3 items-center p-3 text-sm rounded-lg hover:bg-[#343541]/90 transition-colors duration-200 cursor-pointer ${loading ? "disabled:cursor-not-allowed" : ""} ${selectedConversation.id === conversation.id ? "bg-[#343541]/90" : ""}`}
|
||||
onClick={() => onSelectConversation(conversation)}
|
||||
disabled={loading}
|
||||
>
|
||||
<IconMessage
|
||||
className=""
|
||||
size={16}
|
||||
/>
|
||||
|
||||
{isRenaming && selectedConversation.id === conversation.id ? (
|
||||
<input
|
||||
className="flex-1 bg-transparent border-b border-neutral-400 focus:border-neutral-100 text-left overflow-hidden overflow-ellipsis pr-1 outline-none text-white"
|
||||
type="text"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={handleEnterDown}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-hidden whitespace-nowrap overflow-ellipsis pr-1 flex-1 text-left">{conversation.name}</div>
|
||||
)}
|
||||
|
||||
{(isDeleting || isRenaming) && selectedConversation.id === conversation.id && (
|
||||
<div className="flex gap-1 -ml-2">
|
||||
<IconCheck
|
||||
className="min-w-[20px] text-neutral-400 hover:text-neutral-100"
|
||||
size={16}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (isDeleting) {
|
||||
onDeleteConversation(conversation);
|
||||
} else if (isRenaming) {
|
||||
handleRename(conversation);
|
||||
}
|
||||
|
||||
setIsDeleting(false);
|
||||
setIsRenaming(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<IconX
|
||||
className="min-w-[20px] text-neutral-400 hover:text-neutral-100"
|
||||
size={16}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDeleting(false);
|
||||
setIsRenaming(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedConversation.id === conversation.id && !isDeleting && !isRenaming && (
|
||||
<div className="flex gap-1 -ml-2">
|
||||
<IconPencil
|
||||
className="min-w-[20px] text-neutral-400 hover:text-neutral-100"
|
||||
size={18}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsRenaming(true);
|
||||
setRenameValue(selectedConversation.name);
|
||||
}}
|
||||
/>
|
||||
|
||||
<IconTrash
|
||||
className=" min-w-[20px] text-neutral-400 hover:text-neutral-100"
|
||||
size={18}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDeleting(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Conversation } from "@/types";
|
||||
import { IconFileImport } from "@tabler/icons-react";
|
||||
import { FC } from "react";
|
||||
|
||||
interface Props {
|
||||
onImport: (conversations: Conversation[]) => void;
|
||||
}
|
||||
|
||||
export const Import: FC<Props> = ({ onImport }) => {
|
||||
return (
|
||||
<div className="flex py-3 px-3 gap-3 rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white cursor-pointer w-full items-center">
|
||||
<input
|
||||
className="opacity-0 absolute w-[200px] cursor-pointer"
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={(e) => {
|
||||
if (!e.target.files?.length) return;
|
||||
|
||||
const file = e.target.files[0];
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const conversations: Conversation[] = JSON.parse(e.target?.result as string);
|
||||
onImport(conversations);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-3 text-left">
|
||||
<IconFileImport size={16} />
|
||||
<div>Import conversations</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,34 +1,38 @@
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
import { FC } from "react";
|
||||
import { IconX } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { FC } from 'react';
|
||||
|
||||
interface Props {
|
||||
placeholder: string;
|
||||
searchTerm: string;
|
||||
onSearch: (searchTerm: string) => void;
|
||||
}
|
||||
|
||||
export const Search: FC<Props> = ({ searchTerm, onSearch }) => {
|
||||
export const Search: FC<Props> = ({ placeholder, searchTerm, onSearch }) => {
|
||||
const { t } = useTranslation('sidebar');
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onSearch(e.target.value);
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
onSearch("");
|
||||
onSearch('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
className="flex-1 w-full pr-10 bg-[#202123] border border-neutral-600 text-sm rounded-md px-4 py-3 text-white"
|
||||
className="w-full flex-1 rounded-md border border-neutral-600 bg-[#202123] px-4 py-3 pr-10 text-[14px] leading-3 text-white"
|
||||
type="text"
|
||||
placeholder="Search conversations..."
|
||||
placeholder={t(placeholder) || ''}
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
|
||||
{searchTerm && (
|
||||
<IconX
|
||||
className="absolute right-4 text-neutral-300 cursor-pointer hover:text-neutral-400"
|
||||
size={24}
|
||||
className="absolute right-4 cursor-pointer text-neutral-300 hover:text-neutral-400"
|
||||
size={18}
|
||||
onClick={clearSearch}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { Conversation, KeyValuePair } from "@/types";
|
||||
import { IconArrowBarLeft, IconPlus } from "@tabler/icons-react";
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import { Conversations } from "./Conversations";
|
||||
import { Search } from "./Search";
|
||||
import { SidebarSettings } from "./SidebarSettings";
|
||||
|
||||
interface Props {
|
||||
loading: boolean;
|
||||
conversations: Conversation[];
|
||||
lightMode: "light" | "dark";
|
||||
selectedConversation: Conversation;
|
||||
apiKey: string;
|
||||
onNewConversation: () => void;
|
||||
onToggleLightMode: (mode: "light" | "dark") => void;
|
||||
onSelectConversation: (conversation: Conversation) => void;
|
||||
onDeleteConversation: (conversation: Conversation) => void;
|
||||
onToggleSidebar: () => void;
|
||||
onUpdateConversation: (conversation: Conversation, data: KeyValuePair) => void;
|
||||
onApiKeyChange: (apiKey: string) => void;
|
||||
onClearConversations: () => void;
|
||||
onExportConversations: () => void;
|
||||
onImportConversations: (conversations: Conversation[]) => void;
|
||||
}
|
||||
|
||||
export const Sidebar: FC<Props> = ({ loading, conversations, lightMode, selectedConversation, apiKey, onNewConversation, onToggleLightMode, onSelectConversation, onDeleteConversation, onToggleSidebar, onUpdateConversation, onApiKeyChange, onClearConversations, onExportConversations, onImportConversations }) => {
|
||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||
const [filteredConversations, setFilteredConversations] = useState<Conversation[]>(conversations);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchTerm) {
|
||||
setFilteredConversations(conversations.filter((conversation) => conversation.name.toLowerCase().includes(searchTerm.toLowerCase())));
|
||||
} else {
|
||||
setFilteredConversations(conversations);
|
||||
}
|
||||
}, [searchTerm, conversations]);
|
||||
|
||||
return (
|
||||
<div className={`h-full flex flex-none space-y-2 p-2 flex-col bg-[#202123] w-[260px] z-10 sm:relative sm:top-0 absolute top-12 bottom-0`}>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
className="flex gap-3 p-3 items-center w-full sm:w-[200px] rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white cursor-pointer text-sm flex-shrink-0 border border-white/20"
|
||||
onClick={() => {
|
||||
onNewConversation();
|
||||
setSearchTerm("");
|
||||
}}
|
||||
>
|
||||
<IconPlus
|
||||
className=""
|
||||
size={16}
|
||||
/>
|
||||
New chat
|
||||
</button>
|
||||
|
||||
<IconArrowBarLeft
|
||||
className="ml-1 p-1 text-neutral-300 cursor-pointer hover:text-neutral-400 hidden sm:flex"
|
||||
size={32}
|
||||
onClick={onToggleSidebar}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{conversations.length > 1 && (
|
||||
<Search
|
||||
searchTerm={searchTerm}
|
||||
onSearch={setSearchTerm}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex-grow overflow-auto">
|
||||
<Conversations
|
||||
loading={loading}
|
||||
conversations={filteredConversations}
|
||||
selectedConversation={selectedConversation}
|
||||
onSelectConversation={onSelectConversation}
|
||||
onDeleteConversation={(conversation) => {
|
||||
onDeleteConversation(conversation);
|
||||
setSearchTerm("");
|
||||
}}
|
||||
onRenameConversation={(conversation, name) => {
|
||||
onUpdateConversation(conversation, { key: "name", value: name });
|
||||
setSearchTerm("");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SidebarSettings
|
||||
lightMode={lightMode}
|
||||
apiKey={apiKey}
|
||||
onToggleLightMode={onToggleLightMode}
|
||||
onApiKeyChange={onApiKeyChange}
|
||||
onClearConversations={onClearConversations}
|
||||
onExportConversations={onExportConversations}
|
||||
onImportConversations={onImportConversations}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FC } from "react";
|
||||
import { FC } from 'react';
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
@@ -8,12 +8,12 @@ interface Props {
|
||||
|
||||
export const SidebarButton: FC<Props> = ({ text, icon, onClick }) => {
|
||||
return (
|
||||
<div
|
||||
className="flex py-3 px-3 gap-3 rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white cursor-pointer w-full items-center"
|
||||
<button
|
||||
className="flex w-full cursor-pointer select-none items-center gap-3 rounded-md py-3 px-3 text-[14px] leading-3 text-white transition-colors duration-200 hover:bg-gray-500/10"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div>{icon}</div>
|
||||
<div>{text}</div>
|
||||
</div>
|
||||
<span>{text}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Conversation } from "@/types";
|
||||
import { IconFileExport, IconMoon, IconSun } from "@tabler/icons-react";
|
||||
import { FC } from "react";
|
||||
import { ClearConversations } from "./ClearConversations";
|
||||
import { Import } from "./Import";
|
||||
import { Key } from "./Key";
|
||||
import { SidebarButton } from "./SidebarButton";
|
||||
|
||||
interface Props {
|
||||
lightMode: "light" | "dark";
|
||||
apiKey: string;
|
||||
onToggleLightMode: (mode: "light" | "dark") => void;
|
||||
onApiKeyChange: (apiKey: string) => void;
|
||||
onClearConversations: () => void;
|
||||
onExportConversations: () => void;
|
||||
onImportConversations: (conversations: Conversation[]) => void;
|
||||
}
|
||||
|
||||
export const SidebarSettings: FC<Props> = ({ lightMode, apiKey, onToggleLightMode, onApiKeyChange, onClearConversations, onExportConversations, onImportConversations }) => {
|
||||
return (
|
||||
<div className="flex flex-col pt-1 items-center border-t border-white/20 text-sm space-y-1">
|
||||
<ClearConversations onClearConversations={onClearConversations} />
|
||||
|
||||
<Import onImport={onImportConversations} />
|
||||
|
||||
<SidebarButton
|
||||
text="Export conversations"
|
||||
icon={<IconFileExport size={16} />}
|
||||
onClick={() => onExportConversations()}
|
||||
/>
|
||||
|
||||
<SidebarButton
|
||||
text={lightMode === "light" ? "Dark mode" : "Light mode"}
|
||||
icon={lightMode === "light" ? <IconMoon size={16} /> : <IconSun size={16} />}
|
||||
onClick={() => onToggleLightMode(lightMode === "light" ? "dark" : "light")}
|
||||
/>
|
||||
|
||||
<Key
|
||||
apiKey={apiKey}
|
||||
onApiKeyChange={onApiKeyChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
# Google Search Tool
|
||||
|
||||
Use the Google Search API to search the web in Chatbot UI.
|
||||
|
||||
## How To Enable
|
||||
|
||||
1. Create a new project at https://console.developers.google.com/apis/dashboard
|
||||
|
||||
2. Create a new API key at https://console.developers.google.com/apis/credentials
|
||||
|
||||
3. Enable the Custom Search API at https://console.developers.google.com/apis/library/customsearch.googleapis.com
|
||||
|
||||
4. Create a new Custom Search Engine at https://cse.google.com/cse/all
|
||||
|
||||
5. Add your API Key and your Custom Search Engine ID to your .env.local file
|
||||
|
||||
6. You can now select the Google Search Tool in the search tools dropdown
|
||||
|
||||
## Usage Limits
|
||||
|
||||
Google gives you 100 free searches per day. You can increase this limit by creating a billing account.
|
||||
@@ -0,0 +1,60 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: chatbot-ui
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
namespace: chatbot-ui
|
||||
name: chatbot-ui
|
||||
type: Opaque
|
||||
data:
|
||||
OPENAI_API_KEY: <base64 encoded key>
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
namespace: chatbot-ui
|
||||
name: chatbot-ui
|
||||
labels:
|
||||
app: chatbot-ui
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: chatbot-ui
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: chatbot-ui
|
||||
spec:
|
||||
containers:
|
||||
- name: chatbot-ui
|
||||
image: <docker user>/chatbot-ui:latest
|
||||
resources: {}
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
env:
|
||||
- name: OPENAI_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: chatbot-ui
|
||||
key: OPENAI_API_KEY
|
||||
---
|
||||
kind: Service
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
namespace: chatbot-ui
|
||||
name: chatbot-ui
|
||||
labels:
|
||||
app: chatbot-ui
|
||||
spec:
|
||||
ports:
|
||||
- name: http
|
||||
protocol: TCP
|
||||
port: 80
|
||||
targetPort: 3000
|
||||
selector:
|
||||
app: chatbot-ui
|
||||
type: ClusterIP
|
||||
@@ -0,0 +1,28 @@
|
||||
module.exports = {
|
||||
i18n: {
|
||||
defaultLocale: "en",
|
||||
locales: [
|
||||
"bn",
|
||||
"de",
|
||||
"en",
|
||||
"es",
|
||||
"fr",
|
||||
"he",
|
||||
"id",
|
||||
"it",
|
||||
"ja",
|
||||
"ko",
|
||||
"pt",
|
||||
"ru",
|
||||
"sv",
|
||||
"te",
|
||||
"vi",
|
||||
"zh",
|
||||
"ar",
|
||||
],
|
||||
},
|
||||
localePath:
|
||||
typeof window === 'undefined'
|
||||
? require('path').resolve('./public/locales')
|
||||
: '/public/locales',
|
||||
};
|
||||
+5
-4
@@ -1,17 +1,18 @@
|
||||
const { i18n } = require('./next-i18next.config');
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
i18n,
|
||||
reactStrictMode: true,
|
||||
|
||||
webpack(config, { isServer, dev }) {
|
||||
config.experiments = {
|
||||
asyncWebAssembly: true,
|
||||
layers: true
|
||||
layers: true,
|
||||
};
|
||||
|
||||
return config;
|
||||
},
|
||||
images: {
|
||||
unoptimized: true
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
||||
Generated
+5482
-408
File diff suppressed because it is too large
Load Diff
+29
-13
@@ -4,37 +4,53 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build && next export -o dist",
|
||||
"export": "next export",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"tauri": "tauri",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"format": "prettier --write .",
|
||||
"test": "vitest",
|
||||
"coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dqbd/tiktoken": "^1.0.2",
|
||||
"@tabler/icons-react": "^2.9.0",
|
||||
"@tauri-apps/api": "^1.2.0",
|
||||
"@tauri-apps/cli": "^1.2.3",
|
||||
"@types/node": "18.15.0",
|
||||
"@types/react": "18.0.28",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"eslint": "8.36.0",
|
||||
"eslint-config-next": "13.2.4",
|
||||
"eventsource-parser": "^0.1.0",
|
||||
"i18next": "^22.4.13",
|
||||
"next": "13.2.4",
|
||||
"next-i18next": "^13.2.2",
|
||||
"openai": "^3.2.1",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hot-toast": "^2.4.0",
|
||||
"react-i18next": "^12.2.0",
|
||||
"react-markdown": "^8.0.5",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"rehype-mathjax": "^4.0.2",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"typescript": "4.9.5"
|
||||
"remark-math": "^5.1.1",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mozilla/readability": "^0.4.4",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/jsdom": "^21.1.1",
|
||||
"@types/node": "18.15.0",
|
||||
"@types/react": "18.0.28",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"@types/react-syntax-highlighter": "^15.5.6",
|
||||
"@types/uuid": "^9.0.1",
|
||||
"@vitest/coverage-c8": "^0.29.7",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"endent": "^2.1.0",
|
||||
"eslint": "8.36.0",
|
||||
"eslint-config-next": "13.2.4",
|
||||
"gpt-3-encoder": "^1.1.4",
|
||||
"jsdom": "^21.1.1",
|
||||
"postcss": "^8.4.21",
|
||||
"tailwindcss": "^3.2.7"
|
||||
"prettier": "^2.8.7",
|
||||
"prettier-plugin-tailwindcss": "^0.2.5",
|
||||
"tailwindcss": "^3.2.7",
|
||||
"typescript": "4.9.5",
|
||||
"vitest": "^0.29.7"
|
||||
}
|
||||
}
|
||||
|
||||
+12
-7
@@ -1,13 +1,18 @@
|
||||
import "@/styles/globals.css";
|
||||
import type { AppProps } from "next/app";
|
||||
import { Inter } from "next/font/google";
|
||||
import '@/styles/globals.css';
|
||||
import { appWithTranslation } from 'next-i18next';
|
||||
import type { AppProps } from 'next/app';
|
||||
import { Inter } from 'next/font/google';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps<{}>) {
|
||||
function App({ Component, pageProps }: AppProps<{}>) {
|
||||
return (
|
||||
<main className={inter.className}>
|
||||
<div className={inter.className}>
|
||||
<Toaster />
|
||||
<Component {...pageProps} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default appWithTranslation(App);
|
||||
|
||||
+12
-5
@@ -1,10 +1,17 @@
|
||||
import { Html, Head, Main, NextScript } from 'next/document'
|
||||
import { Html, Head, Main, NextScript, DocumentProps } from 'next/document';
|
||||
import i18nextConfig from '../next-i18next.config';
|
||||
|
||||
export default function Document() {
|
||||
type Props = DocumentProps & {
|
||||
// add custom document props
|
||||
};
|
||||
|
||||
export default function Document(props: Props) {
|
||||
const currentLocale =
|
||||
props.__NEXT_DATA__.locale ?? i18nextConfig.i18n.defaultLocale;
|
||||
return (
|
||||
<Html lang="en">
|
||||
<Html lang={currentLocale}>
|
||||
<Head>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes"/>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="Chatbot UI"></meta>
|
||||
</Head>
|
||||
<body>
|
||||
@@ -12,5 +19,5 @@ export default function Document() {
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
+26
-17
@@ -1,13 +1,13 @@
|
||||
import { ChatBody, Message, OpenAIModelID } from "@/types";
|
||||
import { DEFAULT_SYSTEM_PROMPT } from "@/utils/app/const";
|
||||
import { OpenAIStream } from "@/utils/server";
|
||||
import tiktokenModel from "@dqbd/tiktoken/encoders/cl100k_base.json";
|
||||
import { init, Tiktoken } from "@dqbd/tiktoken/lite/init";
|
||||
import { ChatBody, Message } from '@/types/chat';
|
||||
import { DEFAULT_SYSTEM_PROMPT } from '@/utils/app/const';
|
||||
import { OpenAIError, OpenAIStream } from '@/utils/server';
|
||||
import tiktokenModel from '@dqbd/tiktoken/encoders/cl100k_base.json';
|
||||
import { Tiktoken, init } from '@dqbd/tiktoken/lite/init';
|
||||
// @ts-expect-error
|
||||
import wasm from "../../node_modules/@dqbd/tiktoken/lite/tiktoken_bg.wasm?module";
|
||||
import wasm from '../../node_modules/@dqbd/tiktoken/lite/tiktoken_bg.wasm?module';
|
||||
|
||||
export const config = {
|
||||
runtime: "edge"
|
||||
runtime: 'edge',
|
||||
};
|
||||
|
||||
const handler = async (req: Request): Promise<Response> => {
|
||||
@@ -15,17 +15,27 @@ const handler = async (req: Request): Promise<Response> => {
|
||||
const { model, messages, key, prompt } = (await req.json()) as ChatBody;
|
||||
|
||||
await init((imports) => WebAssembly.instantiate(wasm, imports));
|
||||
const encoding = new Tiktoken(tiktokenModel.bpe_ranks, tiktokenModel.special_tokens, tiktokenModel.pat_str);
|
||||
const encoding = new Tiktoken(
|
||||
tiktokenModel.bpe_ranks,
|
||||
tiktokenModel.special_tokens,
|
||||
tiktokenModel.pat_str,
|
||||
);
|
||||
|
||||
const tokenLimit = model.id === OpenAIModelID.GPT_4 ? 6000 : 3000;
|
||||
let tokenCount = 0;
|
||||
let promptToSend = prompt;
|
||||
if (!promptToSend) {
|
||||
promptToSend = DEFAULT_SYSTEM_PROMPT;
|
||||
}
|
||||
|
||||
const prompt_tokens = encoding.encode(promptToSend);
|
||||
|
||||
let tokenCount = prompt_tokens.length;
|
||||
let messagesToSend: Message[] = [];
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i];
|
||||
const tokens = encoding.encode(message.content);
|
||||
|
||||
if (tokenCount + tokens.length > tokenLimit) {
|
||||
if (tokenCount + tokens.length + 1000 > model.tokenLimit) {
|
||||
break;
|
||||
}
|
||||
tokenCount += tokens.length;
|
||||
@@ -34,17 +44,16 @@ const handler = async (req: Request): Promise<Response> => {
|
||||
|
||||
encoding.free();
|
||||
|
||||
let promptToSend = prompt;
|
||||
if (!promptToSend) {
|
||||
promptToSend = DEFAULT_SYSTEM_PROMPT;
|
||||
}
|
||||
|
||||
const stream = await OpenAIStream(model, promptToSend, key, messagesToSend);
|
||||
|
||||
return new Response(stream);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return new Response("Error", { status: 500 });
|
||||
if (error instanceof OpenAIError) {
|
||||
return new Response('Error', { status: 500, statusText: error.message });
|
||||
} else {
|
||||
return new Response('Error', { status: 500 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Message } from '@/types/chat';
|
||||
import { GoogleBody, GoogleSource } from '@/types/google';
|
||||
import { OPENAI_API_HOST } from '@/utils/app/const';
|
||||
import { cleanSourceText } from '@/utils/server/google';
|
||||
import { Readability } from '@mozilla/readability';
|
||||
import endent from 'endent';
|
||||
import jsdom, { JSDOM } from 'jsdom';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse<any>) => {
|
||||
try {
|
||||
const { messages, key, model, googleAPIKey, googleCSEId } =
|
||||
req.body as GoogleBody;
|
||||
|
||||
const userMessage = messages[messages.length - 1];
|
||||
|
||||
const googleRes = await fetch(
|
||||
`https://customsearch.googleapis.com/customsearch/v1?key=${
|
||||
googleAPIKey ? googleAPIKey : process.env.GOOGLE_API_KEY
|
||||
}&cx=${
|
||||
googleCSEId ? googleCSEId : process.env.GOOGLE_CSE_ID
|
||||
}&q=${userMessage.content.trim()}&num=5`,
|
||||
);
|
||||
|
||||
const googleData = await googleRes.json();
|
||||
|
||||
const sources: GoogleSource[] = googleData.items.map((item: any) => ({
|
||||
title: item.title,
|
||||
link: item.link,
|
||||
displayLink: item.displayLink,
|
||||
snippet: item.snippet,
|
||||
image: item.pagemap?.cse_image?.[0]?.src,
|
||||
text: '',
|
||||
}));
|
||||
|
||||
const sourcesWithText: any = await Promise.all(
|
||||
sources.map(async (source) => {
|
||||
try {
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Request timed out')), 5000),
|
||||
);
|
||||
|
||||
const res = (await Promise.race([
|
||||
fetch(source.link),
|
||||
timeoutPromise,
|
||||
])) as any;
|
||||
|
||||
// if (res) {
|
||||
const html = await res.text();
|
||||
|
||||
const virtualConsole = new jsdom.VirtualConsole();
|
||||
virtualConsole.on('error', (error) => {
|
||||
if (!error.message.includes('Could not parse CSS stylesheet')) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
const dom = new JSDOM(html, { virtualConsole });
|
||||
const doc = dom.window.document;
|
||||
const parsed = new Readability(doc).parse();
|
||||
|
||||
if (parsed) {
|
||||
let sourceText = cleanSourceText(parsed.textContent);
|
||||
|
||||
return {
|
||||
...source,
|
||||
// TODO: switch to tokens
|
||||
text: sourceText.slice(0, 2000),
|
||||
} as GoogleSource;
|
||||
}
|
||||
// }
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const filteredSources: GoogleSource[] = sourcesWithText.filter(Boolean);
|
||||
|
||||
const answerPrompt = endent`
|
||||
Provide me with the information I requested. Use the sources to provide an accurate response. Respond in markdown format. Cite the sources you used as a markdown link as you use them at the end of each sentence by number of the source (ex: [[1]](link.com)). Provide an accurate response and then stop. Today's date is ${new Date().toLocaleDateString()}.
|
||||
|
||||
Example Input:
|
||||
What's the weather in San Francisco today?
|
||||
|
||||
Example Sources:
|
||||
[Weather in San Francisco](https://www.google.com/search?q=weather+san+francisco)
|
||||
|
||||
Example Response:
|
||||
It's 70 degrees and sunny in San Francisco today. [[1]](https://www.google.com/search?q=weather+san+francisco)
|
||||
|
||||
Input:
|
||||
${userMessage.content.trim()}
|
||||
|
||||
Sources:
|
||||
${filteredSources.map((source) => {
|
||||
return endent`
|
||||
${source.title} (${source.link}):
|
||||
${source.text}
|
||||
`;
|
||||
})}
|
||||
|
||||
Response:
|
||||
`;
|
||||
|
||||
const answerMessage: Message = { role: 'user', content: answerPrompt };
|
||||
|
||||
const answerRes = await fetch(`${OPENAI_API_HOST}/v1/chat/completions`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${key ? key : process.env.OPENAI_API_KEY}`,
|
||||
...(process.env.OPENAI_ORGANIZATION && {
|
||||
'OpenAI-Organization': process.env.OPENAI_ORGANIZATION,
|
||||
}),
|
||||
},
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
model: model.id,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `Use the sources to provide an accurate response. Respond in markdown format. Cite the sources you used as [1](link), etc, as you use them.`,
|
||||
},
|
||||
answerMessage,
|
||||
],
|
||||
max_tokens: 1000,
|
||||
temperature: 1,
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const { choices: choices2 } = await answerRes.json();
|
||||
const answer = choices2[0].message.content;
|
||||
|
||||
res.status(200).json({ answer });
|
||||
} catch (error) {
|
||||
return new Response('Error', { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
export default handler;
|
||||
+24
-15
@@ -1,7 +1,8 @@
|
||||
import { OpenAIModel, OpenAIModelID, OpenAIModels } from "@/types";
|
||||
import { OpenAIModel, OpenAIModelID, OpenAIModels } from '@/types/openai';
|
||||
import { OPENAI_API_HOST } from '@/utils/app/const';
|
||||
|
||||
export const config = {
|
||||
runtime: "edge"
|
||||
runtime: 'edge',
|
||||
};
|
||||
|
||||
const handler = async (req: Request): Promise<Response> => {
|
||||
@@ -9,42 +10,50 @@ const handler = async (req: Request): Promise<Response> => {
|
||||
const { key } = (await req.json()) as {
|
||||
key: string;
|
||||
};
|
||||
console.log("key", key);
|
||||
|
||||
const response = await fetch("https://api.openai.com/v1/models", {
|
||||
const response = await fetch(`${OPENAI_API_HOST}/v1/models`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${key ? key : process.env.OPENAI_API_KEY}`
|
||||
}
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${key ? key : process.env.OPENAI_API_KEY}`,
|
||||
...(process.env.OPENAI_ORGANIZATION && {
|
||||
'OpenAI-Organization': process.env.OPENAI_ORGANIZATION,
|
||||
})
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error("OpenAI API returned an error");
|
||||
if (response.status === 401) {
|
||||
return new Response(response.body, {
|
||||
status: 500,
|
||||
headers: response.headers,
|
||||
});
|
||||
} else if (response.status !== 200) {
|
||||
console.error(
|
||||
`OpenAI API returned an error ${
|
||||
response.status
|
||||
}: ${await response.text()}`,
|
||||
);
|
||||
throw new Error('OpenAI API returned an error');
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
|
||||
console.log("json", json);
|
||||
|
||||
const models: OpenAIModel[] = json.data
|
||||
.map((model: any) => {
|
||||
for (const [key, value] of Object.entries(OpenAIModelID)) {
|
||||
if (value === model.id) {
|
||||
return {
|
||||
id: model.id,
|
||||
name: OpenAIModels[value].name
|
||||
name: OpenAIModels[value].name,
|
||||
};
|
||||
}
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
console.log("models", models);
|
||||
|
||||
return new Response(JSON.stringify(models), { status: 200 });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return new Response("Error", { status: 500 });
|
||||
return new Response('Error', { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+677
-187
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -3,4 +3,4 @@ module.exports = {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
trailingComma: 'all',
|
||||
singleQuote: true,
|
||||
plugins: [require('prettier-plugin-tailwindcss')]
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"OpenAI API Key Required": " (أوبن أيه أي) OpenAI API Key (مطلوب (مفتاح واجهة برمجة تطبيقات ",
|
||||
"Please set your OpenAI API key in the bottom left of the sidebar.": "يرجى تعيين مفتاح واجهة برمجة تطبيقات أوبن أيه أي الخاص بك في الجزء السفلي الأيسر من الشريط الجانبي",
|
||||
"Stop Generating": "إيقاف التوليد",
|
||||
"Prompt limit is {{maxLength}} characters": "حرفًا {{maxLength}} حد المطالبة هو",
|
||||
"System Prompt": "مطالبة النظام",
|
||||
"You are ChatGPT, a large language model trained by OpenAI. Follow the user's instructions carefully. Respond using markdown.": "أنت شات جبت نموذج لغة كبير تم تدريبه بواسطة أوبن أيه أي. اتبع تعليمات المستخدم بعناية. الرد باستخدام الماركداون",
|
||||
"Enter a prompt": "أدخل المطالبة",
|
||||
"Regenerate response": "إعادة توليد الرد",
|
||||
"Sorry, there was an error.": "عذرًا، حدث خطأ",
|
||||
"Model": "النموذج",
|
||||
"Conversation": "المحادثة",
|
||||
"OR": "أو",
|
||||
"Loading...": "...جاري التحميل",
|
||||
"Type a message...": "...اكتب رسالة",
|
||||
"Error fetching models.": "خطأ في جلب النماذج",
|
||||
"AI": "الذكاء الاصطناعي",
|
||||
"You": "أنت",
|
||||
"Cancel": "Cancel",
|
||||
"Save & Submit": "Save & Submit",
|
||||
"Make sure your OpenAI API key is set in the bottom left of the sidebar.": "تأكد من تعيين مفتاح واجهة برمجة تطبيقات الخاص بك في الجزء السفلي الأيسر من الشريط",
|
||||
"If you completed this step, OpenAI may be experiencing issues.": "من مشاكل OpenAI إذا اكتملت هذه الخطوة، فقد يعاني",
|
||||
|
||||
"click if using a .env.local file": ".env.local انقر إذا كنت تستخدم ملف",
|
||||
|
||||
|
||||
"Message limit is {{maxLength}} characters. You have entered {{valueLength}} characters.": "حرفًا {{maxLength}} حد الرسالة هو {{valueLength}} لقد أدخلت ",
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
"Please enter a message": "يرجى إدخال رسالة",
|
||||
"Chatbot UI is an advanced chatbot kit for OpenAI's chat models aiming to mimic ChatGPT's interface and functionality.": "Chatbot UI هي مجموعة متقدمة للدردشة تستخدم",
|
||||
"Are you sure you want to clear all messages?": "هل أنت متأكد أنك تريد مسح كافة الرسائل؟"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"Copy code": "نسخ الكود",
|
||||
"Copied!": "تم النسخ!",
|
||||
"Enter file name": "أدخل اسم الملف"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"New prompt": "مطلب جديد",
|
||||
"New folder": "مجلد جديد",
|
||||
"No prompts.": "لا يوجد مطالبات.",
|
||||
"Search prompts...": "...البحث عن مطالبات",
|
||||
"Name": "الاسم",
|
||||
"Description": "الوصف",
|
||||
"A description for your prompt.": "وصف لمطلبك",
|
||||
"Prompt": "مطلب",
|
||||
"Prompt content. Use {{}} to denote a variable. Ex: {{name}} is a {{adjective}} {{noun}}": "محتوى المطلب. استخدم {{}} للإشارة إلى متغير. مثال: {{الاسم}} هي {{صفة}} {{اسم}}",
|
||||
"Save": "حفظ"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"New folder": "مجلد جديد",
|
||||
"New chat": "محادثة جديدة",
|
||||
"No conversations.": "لا يوجد محادثات",
|
||||
"Search conversations...": "...البحث عن المحادثات",
|
||||
"OpenAI API Key": " (أوبن أيه أي) OpenAI API Key (مفتاح واجهة برمجة تطبيقات)",
|
||||
"Import data": "استيراد المحادثات",
|
||||
"Are you sure?": "هل أنت متأكد؟",
|
||||
"Clear conversations": "مسح المحادثات",
|
||||
"Export data": "تصدير المحادثات",
|
||||
"Dark mode": "الوضع الداكن",
|
||||
"Light mode": "الوضع الفاتح"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"OpenAI API Key Required": "OpenAI API key বাধ্যতামূলক",
|
||||
"Please set your OpenAI API key in the bottom left of the sidebar.": "দয়া করে আপনার OpenAI API key বামে সাইডবারের নিচের দিকে সেট করুন।",
|
||||
"Stop Generating": "বার্তা জেনারেট করা বন্ধ করুন",
|
||||
"Prompt limit is {{maxLength}} characters": "নির্দেশনা (বার্তা) সীমা সর্বোচ্চ {{maxLength}} অক্ষর",
|
||||
"System Prompt": "সিস্টেম নির্দেশনা (বার্তা)",
|
||||
"You are ChatGPT, a large language model trained by OpenAI. Follow the user's instructions carefully. Respond using markdown.": "তুমি ChatGPT, OpenAI দ্বারা প্রশিক্ষিত একটি বড় ভাষা মডেল। সাবধানে ব্যবহারকারীর নির্দেশাবলী অনুসরণ করুন. মার্কডাউন ব্যবহার করে উত্তর দিন।",
|
||||
"Enter a prompt": "একটি নির্দেশনা (বার্তা) দিন",
|
||||
"Regenerate response": "বার্তা আবার জেনারেট করুন",
|
||||
"Sorry, there was an error.": "দুঃখিত, কোনো একটি সমস্যা হয়েছে।",
|
||||
"Model": "মডেল",
|
||||
"Conversation": "আলাপচারিতা",
|
||||
"OR": "অথবা",
|
||||
"Loading...": "লোড হচ্ছে...",
|
||||
"Type a message...": "কোনো মেসেজ লিখুন...",
|
||||
"Error fetching models.": "মডেল পেতে সমস্যা হচ্ছে।",
|
||||
"AI": "AI",
|
||||
"You": "তুমি",
|
||||
"Cancel": "Cancel",
|
||||
"Save & Submit": "Save & Submit",
|
||||
"Make sure your OpenAI API key is set in the bottom left of the sidebar.": "নিশ্চিত করুন যে আপনার OpenAI API key সাইডবারের নীচে বাম দিকে সেট করা আছে।",
|
||||
"If you completed this step, OpenAI may be experiencing issues.": "আপনি এই ধাপটি সম্পন্ন করে থাকলে, হতে পারে যে OpenAI কোনো সমস্যার সম্মুখীন হয়েছে।",
|
||||
"click if using a .env.local file": "click if using a .env.local file",
|
||||
"Message limit is {{maxLength}} characters. You have entered {{valueLength}} characters.": "বার্তার সর্বোচ্চ সীমা হল {{maxLength}} অক্ষর৷ আপনি {{valueLength}} অক্ষর লিখেছেন।",
|
||||
"Please enter a message": "দয়া করে একটি মেসেজ লিখুন",
|
||||
"Chatbot UI is an advanced chatbot kit for OpenAI's chat models aiming to mimic ChatGPT's interface and functionality.": "Chatbot UI হল OpenAI-এর চ্যাট মডেলগুলির জন্য একটি উন্নত চ্যাটবট কিট যার লক্ষ্য হল ChatGPT-এর ইন্টারফেস এবং কার্যকারিতা অনুকরণ করা।",
|
||||
"Are you sure you want to clear all messages?": "সমস্ত বার্তা মুছে ফেলতে আপনি কি নিশ্চিত?"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"Copy code": "কোড কপি করুন",
|
||||
"Copied!": "কপি করা হয়েছে!",
|
||||
"Enter file name": "ফাইল নাম লিখুন"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"New prompt": "New prompt",
|
||||
"New folder": "New folder",
|
||||
"No prompts.": "No prompts.",
|
||||
"Search prompts...": "Search prompts...",
|
||||
"Name": "Name",
|
||||
"Description": "Description",
|
||||
"A description for your prompt.": "A description for your prompt.",
|
||||
"Prompt": "Prompt",
|
||||
"Prompt content. Use {{}} to denote a variable. Ex: {{name}} is a {{adjective}} {{noun}}": "Prompt content. Use {{}} to denote a variable. Ex: {{name}} is a {{adjective}} {{noun}}",
|
||||
"Save": "Save"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"New folder": "নতুন ফোল্ডার",
|
||||
"New chat": "নতুন আড্ডা",
|
||||
"No conversations.": "কোনো আলাপচারিতা নেই।",
|
||||
"Search conversations...": "আলাপচারিতা খুঁজুন...",
|
||||
"OpenAI API Key": "OpenAI API Key",
|
||||
"Import data": "আলাপচারিতা ইমপোর্ট",
|
||||
"Are you sure?": "আপনি কি নিশ্চিত?",
|
||||
"Clear conversations": "আলাপচারিতা ক্লিয়ার",
|
||||
"Export data": "আলাপচারিতা এক্সপোর্ট",
|
||||
"Dark mode": "ডার্ক মোড",
|
||||
"Light mode": "লাইট মোড"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"OpenAI API Key Required": "OpenAI API-Schlüssel erforderlich",
|
||||
"Please set your OpenAI API key in the bottom left of the sidebar.": "Bitte trage deinen OpenAI API-Schlüssel in der linken unteren Ecke der Seitenleiste ein.",
|
||||
"Stop Generating": "Generieren stoppen",
|
||||
"Prompt limit is {{maxLength}} characters": "Das Eingabelimit liegt bei {{maxLength}} Zeichen",
|
||||
"System Prompt": "Systemaufforderung",
|
||||
"You are ChatGPT, a large language model trained by OpenAI. Follow the user's instructions carefully. Respond using markdown.": "Du bist ChatGPT, ein großes Sprachmodell, das von OpenAI trainiert wurde. Befolge die Anweisungen des Benutzers sorgfältig. Antworte in Markdown-Format.",
|
||||
"Enter a prompt": "Gib eine Anweisung ein",
|
||||
"Regenerate response": "Antwort erneut generieren",
|
||||
"Sorry, there was an error.": "Entschuldigung, es ist ein Fehler aufgetreten.",
|
||||
"Model": "Modell",
|
||||
"Conversation": "Konversation",
|
||||
"OR": "ODER",
|
||||
"Loading...": "Laden...",
|
||||
"Type a message...": "Schreibe eine Nachricht...",
|
||||
"Error fetching models.": "Fehler beim Abrufen der Sprachmodelle.",
|
||||
"AI": "KI",
|
||||
"You": "Du",
|
||||
"Cancel": "Cancel",
|
||||
"Save & Submit": "Save & Submit",
|
||||
"Make sure your OpenAI API key is set in the bottom left of the sidebar.": "Stelle sicher, dass dein OpenAI API-Schlüssel in der unteren linken Ecke der Seitenleiste eingetragen ist.",
|
||||
"If you completed this step, OpenAI may be experiencing issues.": "Wenn dies der Fall ist, könnte OpenAI möglicherweise momentan Probleme haben.",
|
||||
"click if using a .env.local file": "click if using a .env.local file",
|
||||
"Message limit is {{maxLength}} characters. You have entered {{valueLength}} characters.": "Das Nachrichtenlimit beträgt {{maxLength}} Zeichen. Du hast bereits {{valueLength}} Zeichen eingegeben.",
|
||||
"Please enter a message": "Bitte gib eine Nachricht ein",
|
||||
"Chatbot UI is an advanced chatbot kit for OpenAI's chat models aiming to mimic ChatGPT's interface and functionality.": "Chatbot UI ist ein fortschrittliches Chatbot-Toolkit für OpenAI's Chat-Modelle, das darauf abzielt, die Benutzeroberfläche und Funktionalität von ChatGPT nachzuahmen.",
|
||||
"Are you sure you want to clear all messages?": "Bist du sicher, dass du alle Nachrichten löschen möchtest?"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"Copy code": "Code kopieren",
|
||||
"Copied!": "Kopiert!",
|
||||
"Enter file name": "Dateinamen eingeben"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"New prompt": "New prompt",
|
||||
"New folder": "New folder",
|
||||
"No prompts.": "No prompts.",
|
||||
"Search prompts...": "Search prompts...",
|
||||
"Name": "Name",
|
||||
"Description": "Description",
|
||||
"A description for your prompt.": "A description for your prompt.",
|
||||
"Prompt": "Prompt",
|
||||
"Prompt content. Use {{}} to denote a variable. Ex: {{name}} is a {{adjective}} {{noun}}": "Prompt content. Use {{}} to denote a variable. Ex: {{name}} is a {{adjective}} {{noun}}",
|
||||
"Save": "Save"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"New folder": "Neuer Ordner",
|
||||
"New chat": "Neue Konversation",
|
||||
"No conversations.": "Keine Konversationen.",
|
||||
"Search conversations...": "Konversationen suchen...",
|
||||
"OpenAI API Key": "OpenAI API-Schlüssel",
|
||||
"Import data": "Konversationen importieren",
|
||||
"Are you sure?": "Bist du sicher?",
|
||||
"Clear conversations": "Konversationen löschen",
|
||||
"Export data": "Konversationen exportieren",
|
||||
"Dark mode": "Dark Mode",
|
||||
"Light mode": "Light Mode"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"OpenAI API Key Required": "Se requiere la clave de API de OpenAI",
|
||||
"Please set your OpenAI API key in the bottom left of the sidebar.": "Por favor, ingrese su clave de API de OpenAI en la esquina inferior izquierda de la barra lateral.",
|
||||
"Stop Generating": "Dejar de generar",
|
||||
"Prompt limit is {{maxLength}} characters": "El límite del mensaje es de {{maxLength}} caracteres",
|
||||
"System Prompt": "Mensaje del sistema",
|
||||
"You are ChatGPT, a large language model trained by OpenAI. Follow the user's instructions carefully. Respond using markdown.": "Eres ChatGPT, un modelo de lenguaje grande entrenado por OpenAI. Sigue las instrucciones del usuario cuidadosamente. Responde usando markdown.",
|
||||
"Enter a prompt": "Ingrese un mensaje",
|
||||
"Regenerate response": "Regenerar respuesta",
|
||||
"Sorry, there was an error.": "Lo sentimos, ha ocurrido un error.",
|
||||
"Model": "Modelo",
|
||||
"Conversation": "Conversación",
|
||||
"OR": "O",
|
||||
"Loading...": "Cargando...",
|
||||
"Type a message...": "Escriba un mensaje...",
|
||||
"Error fetching models.": "Error al obtener los modelos.",
|
||||
"AI": "IA",
|
||||
"You": "Tú",
|
||||
"Cancel": "Cancel",
|
||||
"Save & Submit": "Save & Submit",
|
||||
"Make sure your OpenAI API key is set in the bottom left of the sidebar.": "Asegúrate de que hayas ingresado la clave de API de OpenAI en la esquina inferior izquierda de la barra lateral.",
|
||||
"If you completed this step, OpenAI may be experiencing issues.": "Si completaste este paso, OpenAI podría estar experimentando problemas.",
|
||||
"click if using a .env.local file": "haz clic si estás utilizando un archivo .env.local",
|
||||
"Message limit is {{maxLength}} characters. You have entered {{valueLength}} characters.": "El límite del mensaje es de {{maxLength}} caracteres. Has ingresado {{valueLength}} caracteres.",
|
||||
"Please enter a message": "Por favor, ingrese un mensaje",
|
||||
"Chatbot UI is an advanced chatbot kit for OpenAI's chat models aiming to mimic ChatGPT's interface and functionality.": "Chatbot UI es un kit avanzado de chatbot para los modelos de chat de OpenAI que busca imitar la interfaz y funcionalidad de ChatGPT.",
|
||||
"Are you sure you want to clear all messages?": "¿Está seguro de que desea borrar todos los mensajes?"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"Copy code": "Copiar código",
|
||||
"Copied!": "¡Copiado!",
|
||||
"Enter file name": "Ingrese el nombre del archivo"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"New prompt": "Nuevo prompt",
|
||||
"New folder": "Nueva carpeta",
|
||||
"No prompts.": "No hay prompts.",
|
||||
"Search prompts...": "Buscar prompts...",
|
||||
"Name": "Nombre",
|
||||
"Description": "Descripción",
|
||||
"A description for your prompt.": "Descripción de su prompt.",
|
||||
"Prompt": "Prompt",
|
||||
"Prompt content. Use {{}} to denote a variable. Ex: {{name}} is a {{adjective}} {{noun}}": "Contenido del prompt. Utilice {{}} para indicar una variable. Ej: {{nombre}} es un {{adjetivo}} {{nombre}}",
|
||||
"Save": "Guardar"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"New folder": "Nueva carpeta",
|
||||
"New chat": "Nueva conversación",
|
||||
"No conversations.": "No hay conversaciones.",
|
||||
"Search conversations...": "Buscar conversaciones...",
|
||||
"OpenAI API Key": "Llave de API de OpenAI",
|
||||
"Import data": "Importar conversaciones",
|
||||
"Are you sure?": "¿Estás seguro?",
|
||||
"Clear conversations": "Borrar conversaciones",
|
||||
"Export data": "Exportar conversaciones",
|
||||
"Dark mode": "Modo oscuro",
|
||||
"Light mode": "Modo claro"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"OpenAI API Key Required": "Clé API OpenAI requise",
|
||||
"Please set your OpenAI API key in the bottom left of the sidebar.": "Veuillez saisir votre clé API OpenAI dans le coin inférieur gauche de la barre latérale.",
|
||||
"Stop Generating": "Interrompre la génération",
|
||||
"Prompt limit is {{maxLength}} characters": "La limite du prompt est de {{maxLength}} caractères",
|
||||
"System Prompt": "Prompt du système",
|
||||
"You are ChatGPT, a large language model trained by OpenAI. Follow the user's instructions carefully. Respond using markdown.": "Vous êtes ChatGPT, un grand modèle linguistique entraîné par OpenAI. Suivez attentivement les instructions de l'utilisateur. Répondez en utilisant Markdown.",
|
||||
"Enter a prompt": "Entrez un prompt",
|
||||
"Regenerate response": "Régénérer la réponse",
|
||||
"Sorry, there was an error.": "Désolé, une erreur est survenue.",
|
||||
"Model": "Modèle",
|
||||
"Conversation": "Conversation",
|
||||
"OR": "OU",
|
||||
"Loading...": "Chargement...",
|
||||
"Type a message...": "Tapez un message...",
|
||||
"Error fetching models.": "Erreur lors de la récupération des modèles.",
|
||||
"AI": "IA",
|
||||
"You": "Vous",
|
||||
"Cancel": "Cancel",
|
||||
"Save & Submit": "Save & Submit",
|
||||
"Make sure your OpenAI API key is set in the bottom left of the sidebar.": "Assurez-vous que votre clé API OpenAI est définie dans le coin inférieur gauche de la barre latérale.",
|
||||
"If you completed this step, OpenAI may be experiencing issues.": "Si vous avez effectué cette étape, OpenAI peut rencontrer des problèmes.",
|
||||
"click if using a .env.local file": "click if using a .env.local file",
|
||||
"Message limit is {{maxLength}} characters. You have entered {{valueLength}} characters.": "La limite de message est de {{maxLength}} caractères. Vous avez saisi {{valueLength}} caractères.",
|
||||
"Please enter a message": "Veuillez entrer un message",
|
||||
"Chatbot UI is an advanced chatbot kit for OpenAI's chat models aiming to mimic ChatGPT's interface and functionality.": "Chatbot UI est un kit de chatbot avancé pour les modèles de chat d'OpenAI visant à imiter l'interface et les fonctionnalités de ChatGPT.",
|
||||
"Are you sure you want to clear all messages?": "Êtes-vous sûr de vouloir effacer tous les messages ?"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"Copy code": "Copier le code",
|
||||
"Copied!": "Copié !",
|
||||
"Enter file name": "Entrez le nom du fichier"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"New prompt": "New prompt",
|
||||
"New folder": "New folder",
|
||||
"No prompts.": "No prompts.",
|
||||
"Search prompts...": "Search prompts...",
|
||||
"Name": "Name",
|
||||
"Description": "Description",
|
||||
"A description for your prompt.": "A description for your prompt.",
|
||||
"Prompt": "Prompt",
|
||||
"Prompt content. Use {{}} to denote a variable. Ex: {{name}} is a {{adjective}} {{noun}}": "Prompt content. Use {{}} to denote a variable. Ex: {{name}} is a {{adjective}} {{noun}}",
|
||||
"Save": "Save"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"New folder": "Nouveau dossier",
|
||||
"New chat": "Nouvelle discussion",
|
||||
"No conversations.": "Aucune conversation.",
|
||||
"Search conversations...": "Rechercher des conversations...",
|
||||
"OpenAI API Key": "Clé API OpenAI",
|
||||
"Import data": "Importer des conversations",
|
||||
"Are you sure?": "Êtes-vous sûr ?",
|
||||
"Clear conversations": "Effacer les conversations",
|
||||
"Export data": "Exporter les conversations",
|
||||
"Dark mode": "Mode sombre",
|
||||
"Light mode": "Mode clair"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"OpenAI API Key Required": "מפתח openAI API",
|
||||
"Please set your OpenAI API key in the bottom left of the sidebar.": "עליך להזין את המפתח האישי שלך בצידו השמאלי התחתון של תפריט הניווט.",
|
||||
"Stop Generating": "עצור תהליך הפקת התשובה",
|
||||
"Prompt limit is {{maxLength}} characters": "אורך התשובה מוגבל ל {{maxLength}} תווים",
|
||||
"System Prompt": "הגדרת בסיס לכל תשובה של המערכת",
|
||||
"You are ChatGPT, a large language model trained by OpenAI. Follow the user's instructions carefully. Respond using markdown.": "You are Hebrew speaking ChatGPT, a large language model trained by OpenAI which responds in Hebrew to any question or User comment. Follow the user's instructions carefully. Respond in Hebrew using markdown.",
|
||||
"Enter a prompt": "הקלד הודעה",
|
||||
"Regenerate response": "הפק תשובה מחדש",
|
||||
"Sorry, there was an error.": "התנצלותנו הכנה, המערכת מדווחת על תקלה",
|
||||
"Model": "מודל",
|
||||
"Conversation": "שיחה",
|
||||
"OR": "או",
|
||||
"Loading...": "טוען...",
|
||||
"Type a message...": "הקלד הודעתך...",
|
||||
"Error fetching models.": "תקלה באיחזור רשימת המודלים",
|
||||
"AI": "המערכת",
|
||||
"You": "אתה",
|
||||
"Cancel": "Cancel",
|
||||
"Save & Submit": "Save & Submit",
|
||||
"Make sure your OpenAI API key is set in the bottom left of the sidebar.": "עליך לוודא שמפתח האישי שלך מוזן בתפריט מצד שמאל",
|
||||
"If you completed this step, OpenAI may be experiencing issues.": "אם טרם השלמת חלק זה יש סבירות גבוהה להתרחשות תקלה",
|
||||
"click if using a .env.local file": "click if using a .env.local file",
|
||||
"Message limit is {{maxLength}} characters. You have entered {{valueLength}} characters.": "מגבלת תווים היא {{maxLength}}. אתה הקלדת עד עכשיו {{valueLength}} תווים.",
|
||||
"Please enter a message": "הקלד את הודעתך",
|
||||
"Chatbot UI is an advanced chatbot kit for OpenAI's chat models aiming to mimic ChatGPT's interface and functionality.": "מערכת הצאטבוט היא ערכה מתקדמת לניהול שיחה המכוונת לחקות את המראה והפונקציונאלית של ChatGPT",
|
||||
"Are you sure you want to clear all messages?": "האם אתה בטוח שברצונך למחוק את כל ההודעות?"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"Copy code": "העתק קוד",
|
||||
"Copied!": "נשמר בזכרון",
|
||||
"Enter file name": "הקלד שם לקובץ"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"New prompt": "פקודת מכונה חדשה",
|
||||
"New folder": "תיקיה חדשה",
|
||||
"No prompts.": "לא נמצאו פקודות מכונות",
|
||||
"Search prompts...": "חיפוש פקודות...",
|
||||
"Name": "שם",
|
||||
"Description": "תיאור",
|
||||
"A description for your prompt.": "תיאור שורת הפקודה למכונה",
|
||||
"Prompt": "פקודה",
|
||||
"Prompt content. Use {{}} to denote a variable. Ex: {{name}} is a {{adjective}} {{noun}}": "תיאור הפקודה. השתמש {{}} להגדרת משתנים. לדוגמא {{שם משתנה}} הוא {{תואר}} {{שם עצם}}",
|
||||
"Save": "Save"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"New folder": "תיקיה חדשה",
|
||||
"New chat": "שיחה חדשה",
|
||||
"No conversations.": "אין שיחות חדשות",
|
||||
"Search conversations...": "חיפוש שיחות...",
|
||||
"OpenAI API Key": "מפתח אישי ל openAI",
|
||||
"Import data": "ייבוא שיחות",
|
||||
"Are you sure?": "אתה בטוח?",
|
||||
"Clear conversations": "ניקוי שיחות",
|
||||
"Export data": "ייצוא שיחות",
|
||||
"Dark mode": "מצב כהה",
|
||||
"Light mode": "מצב בהיר"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"OpenAI API Key Required": "Memerlukan Kunci API OpenAI",
|
||||
"Please set your OpenAI API key in the bottom left of the sidebar.": "Silakan atur kunci API OpenAI Anda di bagian kiri bawah bilah sisi.",
|
||||
"Stop Generating": "Berhenti Menghasilkan",
|
||||
"Prompt limit is {{maxLength}} characters": "Batas karakter untuk prompt adalah {{maxLength}} karakter",
|
||||
"System Prompt": "Prompt Sistem",
|
||||
"You are ChatGPT, a large language model trained by OpenAI. Follow the user's instructions carefully. Respond using markdown.": "Anda adalah ChatGPT, model bahasa besar yang dilatih oleh OpenAI. Ikuti instruksi pengguna dengan hati-hati. Balas menggunakan markdown.",
|
||||
"Enter a prompt": "Masukkan sebuah prompt",
|
||||
"Regenerate response": "Hasilkan kembali respons",
|
||||
"Sorry, there was an error.": "Maaf, terjadi kesalahan.",
|
||||
"Model": "Model",
|
||||
"Conversation": "Percakapan",
|
||||
"OR": "ATAU",
|
||||
"Loading...": "Memuat...",
|
||||
"Type a message...": "Ketik sebuah pesan...",
|
||||
"Error fetching models.": "Kesalahan dalam mengambil model.",
|
||||
"AI": "AI",
|
||||
"You": "Anda",
|
||||
"Cancel": "Cancel",
|
||||
"Save & Submit": "Save & Submit",
|
||||
"Make sure your OpenAI API key is set in the bottom left of the sidebar.": "Pastikan kunci API OpenAI Anda diatur di bagian kiri bawah bilah sisi.",
|
||||
"If you completed this step, OpenAI may be experiencing issues.": "Jika Anda telah menyelesaikan langkah ini, OpenAI mungkin mengalami masalah.",
|
||||
"click if using a .env.local file": "klik jika menggunakan file .env.local",
|
||||
"Message limit is {{maxLength}} characters. You have entered {{valueLength}} characters.": "Batas karakter untuk pesan adalah {{maxLength}} karakter. Anda telah memasukkan {{valueLength}} karakter.",
|
||||
"Please enter a message": "Silakan masukkan sebuah pesan",
|
||||
"Chatbot UI is an advanced chatbot kit for OpenAI's chat models aiming to mimic ChatGPT's interface and functionality.": "Chatbot UI adalah kit chatbot canggih untuk model obrolan OpenAI yang bertujuan meniru antarmuka dan fungsionalitas ChatGPT.",
|
||||
"Are you sure you want to clear all messages?": "Apakah Anda yakin ingin menghapus semua pesan?"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"Copy code": "Salin kode",
|
||||
"Copied!": "Kode disalin!",
|
||||
"Enter file name": "Masukkan nama file"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"New prompt": "New prompt",
|
||||
"New folder": "New folder",
|
||||
"No prompts.": "No prompts.",
|
||||
"Search prompts...": "Search prompts...",
|
||||
"Name": "Name",
|
||||
"Description": "Description",
|
||||
"A description for your prompt.": "A description for your prompt.",
|
||||
"Prompt": "Prompt",
|
||||
"Prompt content. Use {{}} to denote a variable. Ex: {{name}} is a {{adjective}} {{noun}}": "Prompt content. Use {{}} to denote a variable. Ex: {{name}} is a {{adjective}} {{noun}}",
|
||||
"Save": "Save"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"New folder": "Folder baru",
|
||||
"New chat": "Percakapan baru",
|
||||
"No conversations.": "Tidak ada percakapan.",
|
||||
"Search conversations...": "Cari percakapan...",
|
||||
"OpenAI API Key": "Kunci API OpenAI",
|
||||
"Import data": "Impor percakapan",
|
||||
"Are you sure?": "Apakah Anda yakin?",
|
||||
"Clear conversations": "Hapus percakapan",
|
||||
"Export data": "Ekspor percakapan",
|
||||
"Dark mode": "Mode gelap",
|
||||
"Light mode": "Mode terang"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"OpenAI API Key Required": "E' richiesta la chiave API OpenAI",
|
||||
"Please set your OpenAI API key in the bottom left of the sidebar.": "Per favore, inserisci la tua Chiave API OpenAI in basso a sinistra nella barra laterale",
|
||||
"Stop Generating": "Interrompi la generazione",
|
||||
"Prompt limit is {{maxLength}} characters": "Il limite del messaggio è di {{maxLength}} caratteri",
|
||||
"System Prompt": "Prompt del sistema",
|
||||
"You are ChatGPT, a large language model trained by OpenAI. Follow the user's instructions carefully. Respond using markdown.": "Sei ChatGPT, un grande modello di linguaggio addestrato da OpenAI. Segui attentamente le istruzioni dell'utente. Rispondi usando il markdown.",
|
||||
"Enter a prompt": "Inserisci un prompt",
|
||||
"Regenerate response": "Rigenera risposta",
|
||||
"Sorry, there was an error.": "Scusa, si è verificato un errore.",
|
||||
"Model": "Modello",
|
||||
"Conversation": "Conversazione",
|
||||
"OR": "O",
|
||||
"Loading...": "Caricamento...",
|
||||
"Type a message...": "Digita un messaggio...",
|
||||
"Error fetching models.": "Si è verificato un errore nel recupero dei modelli.",
|
||||
"AI": "IA",
|
||||
"You": "Tu",
|
||||
"Cancel": "Annulla",
|
||||
"Save & Submit": "Salva e invia",
|
||||
"Make sure your OpenAI API key is set in the bottom left of the sidebar.": "Assicurati che la tua chiave API OpenAI sia inserita in basso a sinistra nella barra laterale",
|
||||
"If you completed this step, OpenAI may be experiencing issues.": "Se hai completato questo passaggio, OpenAI potrebbe avere problemi.",
|
||||
"click if using a .env.local file": "Fai click se stai utilizzando un file .env.local",
|
||||
"Message limit is {{maxLength}} characters. You have entered {{valueLength}} characters.": "Il limite del messaggio è di {{maxLength}} caratteri. Hai inserito {{valueLength}} caratteri.",
|
||||
"Please enter a message": "Per favore, scrivi un messaggio",
|
||||
"Chatbot UI is an advanced chatbot kit for OpenAI's chat models aiming to mimic ChatGPT's interface and functionality.": "Chatbot UI è un kit avanzato di chatbot per i modelli di chat di OpenAI che mira a imitare l'interfaccia e le funzionalità di ChatGPT.",
|
||||
"Are you sure you want to clear all messages?": "Sei sicuro di voler cancellare tutti i messaggi?"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user