Compare commits
7 Commits
prompt-import
..
mac
| Author | SHA1 | Date | |
|---|---|---|---|
| 65d1af5d06 | |||
| ec20db24de | |||
| 1ebae96dca | |||
| 1594f4d29e | |||
| 8ef65cf5dd | |||
| 683530e522 | |||
| cf93a32851 |
+1
-1
@@ -1,4 +1,4 @@
|
||||
.env
|
||||
.env.local
|
||||
node_modules
|
||||
test-results
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
# Chatbot UI
|
||||
DEFAULT_MODEL=gpt-3.5-turbo
|
||||
NEXT_PUBLIC_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
|
||||
@@ -1,69 +0,0 @@
|
||||
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
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2.1.0
|
||||
|
||||
# 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: .
|
||||
platforms: "linux/amd64,linux/arm64"
|
||||
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
|
||||
@@ -1,24 +0,0 @@
|
||||
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
|
||||
+1
-3
@@ -7,12 +7,11 @@
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
/test-results
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
/dist
|
||||
dist
|
||||
|
||||
# production
|
||||
/build
|
||||
@@ -37,4 +36,3 @@ yarn-error.log*
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.idea
|
||||
pnpm-lock.yaml
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
# 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,8 +19,6 @@ 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
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
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,10 +1,16 @@
|
||||
# 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.
|
||||
|
||||
See a [demo](https://twitter.com/mckaywrigley/status/1640380021423603713?s=46&t=AowqkodyK6B4JccSOxSPew).
|
||||
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).
|
||||
|
||||

|
||||
|
||||
## Updates
|
||||
|
||||
@@ -14,9 +20,24 @@ Expect frequent improvements.
|
||||
|
||||
**Next up:**
|
||||
|
||||
- [ ] Import/Export prompts
|
||||
- [ ] "Bots"
|
||||
- [ ] Sharing
|
||||
- [ ] More custom model settings
|
||||
- [ ] Regenerate & edit responses
|
||||
- [ ] Saving via data export
|
||||
- [ ] Folders
|
||||
- [ ] Prompt templates
|
||||
|
||||
**Recent updates:**
|
||||
|
||||
- [x] Custom system prompt (3/21/23)
|
||||
- [x] Error handling (3/20/23)
|
||||
- [x] GPT-4 support (access required) (3/20/23)
|
||||
- [x] Search conversations (3/19/23)
|
||||
- [x] Code syntax highlighting (3/18/23)
|
||||
- [x] Toggle sidebar (3/18/23)
|
||||
- [x] Conversation naming (3/18/23)
|
||||
- [x] Github flavored markdown (3/18/23)
|
||||
- [x] Add OpenAI API key in app (3/18/23)
|
||||
- [x] Markdown support (3/17/23)
|
||||
|
||||
## Modifications
|
||||
|
||||
@@ -24,7 +45,7 @@ Modify the chat interface in `components/Chat`.
|
||||
|
||||
Modify the sidebar interface in `components/Sidebar`.
|
||||
|
||||
Modify the system prompt in `utils/server/index.ts`.
|
||||
Modify the system prompt in `utils/index.ts`.
|
||||
|
||||
## Deploy
|
||||
|
||||
@@ -40,17 +61,15 @@ 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
|
||||
```
|
||||
|
||||
Pull from ghcr:
|
||||
**Desktop App**
|
||||
|
||||
```
|
||||
docker run -e OPENAI_API_KEY=xxxxxxxx -p 3000:3000 ghcr.io/mckaywrigley/chatbot-ui:main
|
||||
npm run tauri build
|
||||
```
|
||||
|
||||
## Running Locally
|
||||
@@ -75,10 +94,6 @@ 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
|
||||
@@ -89,29 +104,6 @@ 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 |
|
||||
| OPENAI_API_HOST | `https://api.openai.com` | The base url, for Azure use `https://<endpoint>.openai.azure.com` |
|
||||
| OPENAI_API_TYPE | `openai` | The API type, options are `openai` or `azure` |
|
||||
| OPENAI_API_VERSION | `2023-03-15-preview` | Only applicable for Azure OpenAI |
|
||||
| AZURE_DEPLOYMENT_ID | | Needed when Azure OpenAI, Ref [Azure OpenAI API](https://learn.microsoft.com/zh-cn/azure/cognitive-services/openai/reference#completions) |
|
||||
| OPENAI_ORGANIZATION | | Your OpenAI organization ID |
|
||||
| DEFAULT_MODEL | `gpt-3.5-turbo` | The default model to use on new conversations, for Azure use `gpt-35-turbo` |
|
||||
| NEXT_PUBLIC_DEFAULT_SYSTEM_PROMPT | [see here](utils/app/const.ts) | The default system prompt to use on new conversations |
|
||||
| NEXT_PUBLIC_DEFAULT_TEMPERATURE | 1 | The default temperature to use on new conversations |
|
||||
| GOOGLE_API_KEY | | See [Custom Search JSON API documentation][GCSE] |
|
||||
| GOOGLE_CSE_ID | | See [Custom Search JSON API documentation][GCSE] |
|
||||
|
||||
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).
|
||||
|
||||
[GCSE]: https://developers.google.com/custom-search/v1/overview
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
import { DEFAULT_SYSTEM_PROMPT, DEFAULT_TEMPERATURE } from '@/utils/app/const';
|
||||
import {
|
||||
cleanData,
|
||||
isExportFormatV1,
|
||||
isExportFormatV2,
|
||||
isExportFormatV3,
|
||||
isExportFormatV4,
|
||||
isLatestExportFormat,
|
||||
} from '@/utils/app/importExport';
|
||||
|
||||
import { ExportFormatV1, ExportFormatV2, ExportFormatV4 } from '@/types/export';
|
||||
import { OpenAIModelID, OpenAIModels } from '@/types/openai';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
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,
|
||||
temperature: DEFAULT_TEMPERATURE,
|
||||
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,
|
||||
temperature: DEFAULT_TEMPERATURE,
|
||||
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,
|
||||
temperature: DEFAULT_TEMPERATURE,
|
||||
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,
|
||||
temperature: DEFAULT_TEMPERATURE,
|
||||
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,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { MouseEventHandler, ReactElement } from 'react';
|
||||
|
||||
interface Props {
|
||||
handleClick: MouseEventHandler<HTMLButtonElement>;
|
||||
children: ReactElement;
|
||||
}
|
||||
|
||||
const SidebarActionButton = ({ handleClick, children }: Props) => (
|
||||
<button
|
||||
className="min-w-[20px] p-1 text-neutral-400 hover:text-neutral-100"
|
||||
onClick={handleClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
export default SidebarActionButton;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './SidebarActionButton';
|
||||
+64
-463
@@ -1,437 +1,65 @@
|
||||
import { IconClearAll, IconSettings } from '@tabler/icons-react';
|
||||
import {
|
||||
MutableRefObject,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { getEndpoint } from '@/utils/app/api';
|
||||
import {
|
||||
saveConversation,
|
||||
saveConversations,
|
||||
updateConversation,
|
||||
} from '@/utils/app/conversation';
|
||||
import { throttle } from '@/utils/data/throttle';
|
||||
|
||||
import { ChatBody, Conversation, Message } from '@/types/chat';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
|
||||
import HomeContext from '@/pages/api/home/home.context';
|
||||
|
||||
import Spinner from '../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';
|
||||
import { TemperatureSlider } from './Temperature';
|
||||
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";
|
||||
|
||||
interface Props {
|
||||
conversation: Conversation;
|
||||
models: OpenAIModel[];
|
||||
messageIsStreaming: boolean;
|
||||
modelError: boolean;
|
||||
messageError: boolean;
|
||||
loading: boolean;
|
||||
lightMode: "light" | "dark";
|
||||
onSend: (message: Message, isResend: boolean) => void;
|
||||
onUpdateConversation: (conversation: Conversation, data: KeyValuePair) => void;
|
||||
stopConversationRef: MutableRefObject<boolean>;
|
||||
}
|
||||
|
||||
export const Chat = memo(({ stopConversationRef }: Props) => {
|
||||
const { t } = useTranslation('chat');
|
||||
|
||||
const {
|
||||
state: {
|
||||
selectedConversation,
|
||||
conversations,
|
||||
models,
|
||||
apiKey,
|
||||
pluginKeys,
|
||||
serverSideApiKeyIsSet,
|
||||
messageIsStreaming,
|
||||
modelError,
|
||||
loading,
|
||||
prompts,
|
||||
},
|
||||
handleUpdateConversation,
|
||||
dispatch: homeDispatch,
|
||||
} = useContext(HomeContext);
|
||||
|
||||
export const Chat: FC<Props> = ({ conversation, models, messageIsStreaming, modelError, messageError, loading, lightMode, onSend, onUpdateConversation, stopConversationRef }) => {
|
||||
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 chatContainerRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (message: Message, deleteCount = 0, plugin: Plugin | null = null) => {
|
||||
if (selectedConversation) {
|
||||
let updatedConversation: Conversation;
|
||||
if (deleteCount) {
|
||||
const updatedMessages = [...selectedConversation.messages];
|
||||
for (let i = 0; i < deleteCount; i++) {
|
||||
updatedMessages.pop();
|
||||
}
|
||||
updatedConversation = {
|
||||
...selectedConversation,
|
||||
messages: [...updatedMessages, message],
|
||||
};
|
||||
} else {
|
||||
updatedConversation = {
|
||||
...selectedConversation,
|
||||
messages: [...selectedConversation.messages, message],
|
||||
};
|
||||
}
|
||||
homeDispatch({
|
||||
field: 'selectedConversation',
|
||||
value: updatedConversation,
|
||||
});
|
||||
homeDispatch({ field: 'loading', value: true });
|
||||
homeDispatch({ field: 'messageIsStreaming', value: true });
|
||||
const chatBody: ChatBody = {
|
||||
model: updatedConversation.model,
|
||||
messages: updatedConversation.messages,
|
||||
key: apiKey,
|
||||
prompt: updatedConversation.prompt,
|
||||
temperature: updatedConversation.temperature,
|
||||
};
|
||||
const endpoint = getEndpoint(plugin);
|
||||
let body;
|
||||
if (!plugin) {
|
||||
body = JSON.stringify(chatBody);
|
||||
} else {
|
||||
body = JSON.stringify({
|
||||
...chatBody,
|
||||
googleAPIKey: pluginKeys
|
||||
.find((key) => key.pluginId === 'google-search')
|
||||
?.requiredKeys.find((key) => key.key === 'GOOGLE_API_KEY')?.value,
|
||||
googleCSEId: pluginKeys
|
||||
.find((key) => key.pluginId === 'google-search')
|
||||
?.requiredKeys.find((key) => key.key === 'GOOGLE_CSE_ID')?.value,
|
||||
});
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
signal: controller.signal,
|
||||
body,
|
||||
});
|
||||
if (!response.ok) {
|
||||
homeDispatch({ field: 'loading', value: false });
|
||||
homeDispatch({ field: 'messageIsStreaming', value: false });
|
||||
toast.error(response.statusText);
|
||||
return;
|
||||
}
|
||||
const data = response.body;
|
||||
if (!data) {
|
||||
homeDispatch({ field: 'loading', value: false });
|
||||
homeDispatch({ field: 'messageIsStreaming', value: false });
|
||||
return;
|
||||
}
|
||||
if (!plugin) {
|
||||
if (updatedConversation.messages.length === 1) {
|
||||
const { content } = message;
|
||||
const customName =
|
||||
content.length > 30 ? content.substring(0, 30) + '...' : content;
|
||||
updatedConversation = {
|
||||
...updatedConversation,
|
||||
name: customName,
|
||||
};
|
||||
}
|
||||
homeDispatch({ field: 'loading', value: false });
|
||||
const reader = data.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let done = false;
|
||||
let isFirst = true;
|
||||
let text = '';
|
||||
while (!done) {
|
||||
if (stopConversationRef.current === true) {
|
||||
controller.abort();
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
const { value, done: doneReading } = await reader.read();
|
||||
done = doneReading;
|
||||
const chunkValue = decoder.decode(value);
|
||||
text += chunkValue;
|
||||
if (isFirst) {
|
||||
isFirst = false;
|
||||
const updatedMessages: Message[] = [
|
||||
...updatedConversation.messages,
|
||||
{ role: 'assistant', content: chunkValue },
|
||||
];
|
||||
updatedConversation = {
|
||||
...updatedConversation,
|
||||
messages: updatedMessages,
|
||||
};
|
||||
homeDispatch({
|
||||
field: 'selectedConversation',
|
||||
value: updatedConversation,
|
||||
});
|
||||
} else {
|
||||
const updatedMessages: Message[] =
|
||||
updatedConversation.messages.map((message, index) => {
|
||||
if (index === updatedConversation.messages.length - 1) {
|
||||
return {
|
||||
...message,
|
||||
content: text,
|
||||
};
|
||||
}
|
||||
return message;
|
||||
});
|
||||
updatedConversation = {
|
||||
...updatedConversation,
|
||||
messages: updatedMessages,
|
||||
};
|
||||
homeDispatch({
|
||||
field: 'selectedConversation',
|
||||
value: updatedConversation,
|
||||
});
|
||||
}
|
||||
}
|
||||
saveConversation(updatedConversation);
|
||||
const updatedConversations: Conversation[] = conversations.map(
|
||||
(conversation) => {
|
||||
if (conversation.id === selectedConversation.id) {
|
||||
return updatedConversation;
|
||||
}
|
||||
return conversation;
|
||||
},
|
||||
);
|
||||
if (updatedConversations.length === 0) {
|
||||
updatedConversations.push(updatedConversation);
|
||||
}
|
||||
homeDispatch({ field: 'conversations', value: updatedConversations });
|
||||
saveConversations(updatedConversations);
|
||||
homeDispatch({ field: 'messageIsStreaming', value: false });
|
||||
} else {
|
||||
const { answer } = await response.json();
|
||||
const updatedMessages: Message[] = [
|
||||
...updatedConversation.messages,
|
||||
{ role: 'assistant', content: answer },
|
||||
];
|
||||
updatedConversation = {
|
||||
...updatedConversation,
|
||||
messages: updatedMessages,
|
||||
};
|
||||
homeDispatch({
|
||||
field: 'selectedConversation',
|
||||
value: updateConversation,
|
||||
});
|
||||
saveConversation(updatedConversation);
|
||||
const updatedConversations: Conversation[] = conversations.map(
|
||||
(conversation) => {
|
||||
if (conversation.id === selectedConversation.id) {
|
||||
return updatedConversation;
|
||||
}
|
||||
return conversation;
|
||||
},
|
||||
);
|
||||
if (updatedConversations.length === 0) {
|
||||
updatedConversations.push(updatedConversation);
|
||||
}
|
||||
homeDispatch({ field: 'conversations', value: updatedConversations });
|
||||
saveConversations(updatedConversations);
|
||||
homeDispatch({ field: 'loading', value: false });
|
||||
homeDispatch({ field: 'messageIsStreaming', value: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
apiKey,
|
||||
conversations,
|
||||
pluginKeys,
|
||||
selectedConversation,
|
||||
stopConversationRef,
|
||||
],
|
||||
);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (autoScrollEnabled) {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [autoScrollEnabled]);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (chatContainerRef.current) {
|
||||
const { scrollTop, scrollHeight, clientHeight } =
|
||||
chatContainerRef.current;
|
||||
const bottomTolerance = 30;
|
||||
|
||||
if (scrollTop + clientHeight < scrollHeight - bottomTolerance) {
|
||||
setAutoScrollEnabled(false);
|
||||
setShowScrollDownButton(true);
|
||||
} else {
|
||||
setAutoScrollEnabled(true);
|
||||
setShowScrollDownButton(false);
|
||||
}
|
||||
}
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "auto" });
|
||||
};
|
||||
|
||||
const handleScrollDown = () => {
|
||||
chatContainerRef.current?.scrollTo({
|
||||
top: chatContainerRef.current.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
const handleSettings = () => {
|
||||
setShowSettings(!showSettings);
|
||||
};
|
||||
|
||||
const onClearAll = () => {
|
||||
if (
|
||||
confirm(t<string>('Are you sure you want to clear all messages?')) &&
|
||||
selectedConversation
|
||||
) {
|
||||
handleUpdateConversation(selectedConversation, {
|
||||
key: 'messages',
|
||||
value: [],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const scrollDown = () => {
|
||||
if (autoScrollEnabled) {
|
||||
messagesEndRef.current?.scrollIntoView(true);
|
||||
}
|
||||
};
|
||||
const throttledScrollDown = throttle(scrollDown, 250);
|
||||
|
||||
// useEffect(() => {
|
||||
// console.log('currentMessage', currentMessage);
|
||||
// if (currentMessage) {
|
||||
// handleSend(currentMessage);
|
||||
// homeDispatch({ field: 'currentMessage', value: undefined });
|
||||
// }
|
||||
// }, [currentMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
throttledScrollDown();
|
||||
selectedConversation &&
|
||||
setCurrentMessage(
|
||||
selectedConversation.messages[selectedConversation.messages.length - 2],
|
||||
);
|
||||
}, [selectedConversation, 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]);
|
||||
scrollToBottom();
|
||||
}, [conversation.messages]);
|
||||
|
||||
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 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>
|
||||
) : modelError ? (
|
||||
<ErrorMessageDiv error={modelError} />
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="max-h-full overflow-x-hidden"
|
||||
ref={chatContainerRef}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{selectedConversation?.messages.length === 0 ? (
|
||||
<div className="overflow-scroll max-h-full">
|
||||
{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>
|
||||
<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>
|
||||
|
||||
{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 />
|
||||
|
||||
<SystemPrompt
|
||||
conversation={selectedConversation}
|
||||
prompts={prompts}
|
||||
onChangePrompt={(prompt) =>
|
||||
handleUpdateConversation(selectedConversation, {
|
||||
key: 'prompt',
|
||||
value: prompt,
|
||||
})
|
||||
}
|
||||
<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 })}
|
||||
/>
|
||||
|
||||
<TemperatureSlider
|
||||
label="Temperature"
|
||||
onChangeTemperature={(temperature) =>
|
||||
handleUpdateConversation(selectedConversation, {
|
||||
key: 'temperature',
|
||||
value: temperature,
|
||||
})
|
||||
}
|
||||
<SystemPrompt
|
||||
conversation={conversation}
|
||||
onChangePrompt={(prompt) => onUpdateConversation(conversation, { key: "prompt", value: prompt })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -439,74 +67,47 @@ export const Chat = memo(({ stopConversationRef }: Props) => {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="sticky top-0 z-10 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')}: {selectedConversation?.model.name} | {t('Temp')}
|
||||
: {selectedConversation?.temperature} |
|
||||
<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 />
|
||||
</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>
|
||||
|
||||
{selectedConversation?.messages.map((message, index) => (
|
||||
{conversation.messages.map((message, index) => (
|
||||
<ChatMessage
|
||||
key={index}
|
||||
message={message}
|
||||
messageIndex={index}
|
||||
onEdit={(editedMessage) => {
|
||||
setCurrentMessage(editedMessage);
|
||||
// discard edited message and the ones that come after then resend
|
||||
handleSend(
|
||||
editedMessage,
|
||||
selectedConversation?.messages.length - index,
|
||||
);
|
||||
}}
|
||||
lightMode={lightMode}
|
||||
/>
|
||||
))}
|
||||
|
||||
{loading && <ChatLoader />}
|
||||
|
||||
<div
|
||||
className="h-[162px] bg-white dark:bg-[#343541]"
|
||||
className="bg-white dark:bg-[#343541] h-[162px]"
|
||||
ref={messagesEndRef}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChatInput
|
||||
stopConversationRef={stopConversationRef}
|
||||
textareaRef={textareaRef}
|
||||
onSend={(message, plugin) => {
|
||||
setCurrentMessage(message);
|
||||
handleSend(message, 0, plugin);
|
||||
}}
|
||||
onScrollDownClick={handleScrollDown}
|
||||
onRegenerate={() => {
|
||||
if (currentMessage) {
|
||||
handleSend(currentMessage, 2, null);
|
||||
}
|
||||
}}
|
||||
showScrollDownButton={showScrollDownButton}
|
||||
/>
|
||||
{messageError ? (
|
||||
<Regenerate
|
||||
onRegenerate={() => {
|
||||
if (currentMessage) {
|
||||
onSend(currentMessage, true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ChatInput
|
||||
stopConversationRef={stopConversationRef}
|
||||
messageIsStreaming={messageIsStreaming}
|
||||
onSend={(message) => {
|
||||
setCurrentMessage(message);
|
||||
onSend(message, false);
|
||||
}}
|
||||
model={conversation.model}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Chat.displayName = 'Chat';
|
||||
};
|
||||
|
||||
+60
-322
@@ -1,90 +1,30 @@
|
||||
import {
|
||||
IconArrowDown,
|
||||
IconBolt,
|
||||
IconBrandGoogle,
|
||||
IconPlayerStop,
|
||||
IconRepeat,
|
||||
IconSend,
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
KeyboardEvent,
|
||||
MutableRefObject,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { Message } from '@/types/chat';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { Prompt } from '@/types/prompt';
|
||||
|
||||
import HomeContext from '@/pages/api/home/home.context';
|
||||
|
||||
import { PluginSelect } from './PluginSelect';
|
||||
import { PromptList } from './PromptList';
|
||||
import { VariableModal } from './VariableModal';
|
||||
import { Message, OpenAIModel, OpenAIModelID } from "@/types";
|
||||
import { IconPlayerStop, IconSend } from "@tabler/icons-react";
|
||||
import { FC, KeyboardEvent, MutableRefObject, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
onSend: (message: Message, plugin: Plugin | null) => void;
|
||||
onRegenerate: () => void;
|
||||
onScrollDownClick: () => void;
|
||||
messageIsStreaming: boolean;
|
||||
onSend: (message: Message) => void;
|
||||
model: OpenAIModel;
|
||||
stopConversationRef: MutableRefObject<boolean>;
|
||||
textareaRef: MutableRefObject<HTMLTextAreaElement | null>;
|
||||
showScrollDownButton: boolean;
|
||||
}
|
||||
|
||||
export const ChatInput = ({
|
||||
onSend,
|
||||
onRegenerate,
|
||||
onScrollDownClick,
|
||||
stopConversationRef,
|
||||
textareaRef,
|
||||
showScrollDownButton
|
||||
}: Props) => {
|
||||
const { t } = useTranslation('chat');
|
||||
|
||||
const {
|
||||
state: { selectedConversation, messageIsStreaming, prompts },
|
||||
|
||||
dispatch: homeDispatch,
|
||||
} = useContext(HomeContext);
|
||||
|
||||
export const ChatInput: FC<Props> = ({ onSend, messageIsStreaming, model, stopConversationRef }) => {
|
||||
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 promptListRef = useRef<HTMLUListElement | null>(null);
|
||||
|
||||
const filteredPrompts = prompts.filter((prompt) =>
|
||||
prompt.name.toLowerCase().includes(promptInputValue.toLowerCase()),
|
||||
);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
const maxLength = selectedConversation?.model.maxLength;
|
||||
const maxLength = model.id === OpenAIModelID.GPT_3_5 ? 12000 : 24000;
|
||||
|
||||
if (maxLength && value.length > maxLength) {
|
||||
alert(
|
||||
t(
|
||||
`Message limit is {{maxLength}} characters. You have entered {{valueLength}} characters.`,
|
||||
{ maxLength, valueLength: value.length },
|
||||
),
|
||||
);
|
||||
if (value.length > maxLength) {
|
||||
alert(`Message limit is ${maxLength} characters`);
|
||||
return;
|
||||
}
|
||||
|
||||
setContent(value);
|
||||
updatePromptListVisibility(value);
|
||||
};
|
||||
|
||||
const handleSend = () => {
|
||||
@@ -93,240 +33,74 @@ export const ChatInput = ({
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
alert(t('Please enter a message'));
|
||||
alert("Please enter a message");
|
||||
return;
|
||||
}
|
||||
|
||||
onSend({ role: 'user', content }, plugin);
|
||||
setContent('');
|
||||
setPlugin(null);
|
||||
onSend({ role: "user", content });
|
||||
setContent("");
|
||||
|
||||
if (window.innerWidth < 640 && textareaRef && textareaRef.current) {
|
||||
textareaRef.current.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopConversation = () => {
|
||||
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() {
|
||||
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 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">
|
||||
<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">
|
||||
{messageIsStreaming && (
|
||||
<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"
|
||||
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"
|
||||
onClick={handleStopConversation}
|
||||
>
|
||||
<IconPlayerStop size={16} /> {t('Stop Generating')}
|
||||
<IconPlayerStop
|
||||
size={16}
|
||||
className="inline-block mb-[2px]"
|
||||
/>{" "}
|
||||
Stop Generating
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!messageIsStreaming &&
|
||||
selectedConversation &&
|
||||
selectedConversation.messages.length > 0 && (
|
||||
<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 rounded bg-white dark:bg-[#343541]">
|
||||
<PluginSelect
|
||||
plugin={plugin}
|
||||
onKeyDown={(e: any) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setShowPluginSelect(false);
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
onPluginChange={(plugin: Plugin) => {
|
||||
setPlugin(plugin);
|
||||
setShowPluginSelect(false);
|
||||
|
||||
if (textareaRef && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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)]">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
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"
|
||||
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"
|
||||
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={
|
||||
t('Type a message or type "/" to select a prompt...') || ''
|
||||
}
|
||||
placeholder="Type a message..."
|
||||
value={content}
|
||||
rows={1}
|
||||
onCompositionStart={() => setIsTyping(true)}
|
||||
@@ -336,50 +110,17 @@ export const ChatInput = ({
|
||||
/>
|
||||
|
||||
<button
|
||||
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"
|
||||
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"
|
||||
onClick={handleSend}
|
||||
>
|
||||
{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>
|
||||
|
||||
{showScrollDownButton && (
|
||||
<div className="absolute bottom-12 right-0 lg:bottom-0 lg:-right-10">
|
||||
<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={onScrollDownClick}
|
||||
>
|
||||
<IconArrowDown size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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)}
|
||||
<IconSend
|
||||
size={16}
|
||||
className="opacity-60"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<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">
|
||||
<a
|
||||
href="https://github.com/mckaywrigley/chatbot-ui"
|
||||
target="_blank"
|
||||
@@ -388,10 +129,7 @@ export const ChatInput = ({
|
||||
>
|
||||
ChatBot UI
|
||||
</a>
|
||||
.{' '}
|
||||
{t(
|
||||
"Chatbot UI is an advanced chatbot kit for OpenAI's chat models aiming to mimic ChatGPT's interface and functionality.",
|
||||
)}
|
||||
. Chatbot UI is an advanced chatbot kit for OpenAI's chat models aiming to mimic ChatGPT's interface and functionality.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import { IconRobot } from '@tabler/icons-react';
|
||||
import { IconDots } from '@tabler/icons-react';
|
||||
import { FC } from 'react';
|
||||
import { IconDots } from "@tabler/icons-react";
|
||||
import { FC } from "react";
|
||||
|
||||
interface Props { }
|
||||
interface Props {}
|
||||
|
||||
export const ChatLoader: FC<Props> = () => {
|
||||
return (
|
||||
<div
|
||||
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' }}
|
||||
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" }}
|
||||
>
|
||||
<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] items-end">
|
||||
<IconRobot size={30} />
|
||||
</div>
|
||||
<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>
|
||||
<IconDots className="animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+50
-282
@@ -1,297 +1,65 @@
|
||||
import {
|
||||
IconCheck,
|
||||
IconCopy,
|
||||
IconEdit,
|
||||
IconRobot,
|
||||
IconTrash,
|
||||
IconUser,
|
||||
} from '@tabler/icons-react';
|
||||
import { FC, memo, useContext, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { updateConversation } from '@/utils/app/conversation';
|
||||
|
||||
import { Message } from '@/types/chat';
|
||||
|
||||
import HomeContext from '@/pages/api/home/home.context';
|
||||
|
||||
import { CodeBlock } from '../Markdown/CodeBlock';
|
||||
import { MemoizedReactMarkdown } from '../Markdown/MemoizedReactMarkdown';
|
||||
|
||||
import rehypeMathjax from 'rehype-mathjax';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math';
|
||||
import { Message } from "@/types";
|
||||
import { FC } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { CodeBlock } from "../Markdown/CodeBlock";
|
||||
|
||||
interface Props {
|
||||
message: Message;
|
||||
messageIndex: number;
|
||||
onEdit?: (editedMessage: Message) => void
|
||||
lightMode: "light" | "dark";
|
||||
}
|
||||
|
||||
export const ChatMessage: FC<Props> = memo(({ message, messageIndex, onEdit }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
|
||||
const {
|
||||
state: { selectedConversation, conversations, currentMessage },
|
||||
dispatch: homeDispatch,
|
||||
} = useContext(HomeContext);
|
||||
|
||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||
const [isTyping, setIsTyping] = useState<boolean>(false);
|
||||
const [messageContent, setMessageContent] = useState(message.content);
|
||||
const [messagedCopied, setMessageCopied] = useState(false);
|
||||
|
||||
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) {
|
||||
if (selectedConversation && onEdit) {
|
||||
onEdit({ ...message, content: messageContent });
|
||||
}
|
||||
}
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleDeleteMessage = () => {
|
||||
if (!selectedConversation) return;
|
||||
|
||||
const { messages } = selectedConversation;
|
||||
const findIndex = messages.findIndex((elm) => elm === message);
|
||||
|
||||
if (findIndex < 0) return;
|
||||
|
||||
if (
|
||||
findIndex < messages.length - 1 &&
|
||||
messages[findIndex + 1].role === 'assistant'
|
||||
) {
|
||||
messages.splice(findIndex, 2);
|
||||
} else {
|
||||
messages.splice(findIndex, 1);
|
||||
}
|
||||
const updatedConversation = {
|
||||
...selectedConversation,
|
||||
messages,
|
||||
};
|
||||
|
||||
const { single, all } = updateConversation(
|
||||
updatedConversation,
|
||||
conversations,
|
||||
);
|
||||
homeDispatch({ field: 'selectedConversation', value: single });
|
||||
homeDispatch({ field: 'conversations', value: all });
|
||||
};
|
||||
|
||||
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(() => {
|
||||
setMessageContent(message.content);
|
||||
}, [message.content]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'inherit';
|
||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
export const ChatMessage: FC<Props> = ({ message, lightMode }) => {
|
||||
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' }}
|
||||
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="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} />
|
||||
<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>
|
||||
|
||||
<div className="prose dark:prose-invert mt-[-2px]">
|
||||
{message.role === "user" ? (
|
||||
<div className="prose dark:prose-invert whitespace-pre-wrap">{message.content}</div>
|
||||
) : (
|
||||
<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}
|
||||
<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}
|
||||
/>
|
||||
) : (
|
||||
<code
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{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
|
||||
? 'bottom-1 right-3'
|
||||
: 'right-6 top-[26px]'
|
||||
}
|
||||
`}
|
||||
onClick={toggleEditing}
|
||||
>
|
||||
<IconEdit size={20} />
|
||||
</button>
|
||||
<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
|
||||
? 'bottom-1 right-3'
|
||||
: 'right-0 top-[26px]'
|
||||
}
|
||||
`}
|
||||
onClick={handleDeleteMessage}
|
||||
>
|
||||
<IconTrash size={20} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={`absolute ${
|
||||
window.innerWidth < 640
|
||||
? 'bottom-1 right-3'
|
||||
: 'right-0 top-[26px] m-0'
|
||||
}`}
|
||||
>
|
||||
{messagedCopied ? (
|
||||
<IconCheck
|
||||
size={20}
|
||||
className="text-green-500 dark:text-green-400"
|
||||
/>
|
||||
) : (
|
||||
<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}
|
||||
>
|
||||
<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 ? (
|
||||
<CodeBlock
|
||||
key={Math.random()}
|
||||
language={(match && match[1]) || ''}
|
||||
value={String(children).replace(/\n$/, '')}
|
||||
{...props}
|
||||
/>
|
||||
) : (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
table({ children }) {
|
||||
return (
|
||||
<table className="border-collapse border border-black px-3 py-1 dark:border-white">
|
||||
{children}
|
||||
</table>
|
||||
);
|
||||
},
|
||||
th({ children }) {
|
||||
return (
|
||||
<th className="break-words border border-black bg-gray-500 px-3 py-1 text-white dark:border-white">
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
},
|
||||
td({ children }) {
|
||||
return (
|
||||
<td className="break-words border border-black px-3 py-1 dark:border-white">
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{message.content}
|
||||
</MemoizedReactMarkdown>
|
||||
</>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
ChatMessage.displayName = 'ChatMessage';
|
||||
};
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { IconCircleX } from '@tabler/icons-react';
|
||||
import { FC } from 'react';
|
||||
|
||||
import { ErrorMessage } from '@/types/error';
|
||||
|
||||
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,66 +1,33 @@
|
||||
import { IconExternalLink } from '@tabler/icons-react';
|
||||
import { useContext } from 'react';
|
||||
import { OpenAIModel } from "@/types";
|
||||
import { FC } from "react";
|
||||
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { OpenAIModel } from '@/types/openai';
|
||||
|
||||
import HomeContext from '@/pages/api/home/home.context';
|
||||
|
||||
export const ModelSelect = () => {
|
||||
const { t } = useTranslation('chat');
|
||||
|
||||
const {
|
||||
state: { selectedConversation, models, defaultModelId },
|
||||
handleUpdateConversation,
|
||||
dispatch: homeDispatch,
|
||||
} = useContext(HomeContext);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
selectedConversation &&
|
||||
handleUpdateConversation(selectedConversation, {
|
||||
key: 'model',
|
||||
value: models.find(
|
||||
(model) => model.id === e.target.value,
|
||||
) as OpenAIModel,
|
||||
});
|
||||
};
|
||||
interface Props {
|
||||
model: OpenAIModel;
|
||||
models: OpenAIModel[];
|
||||
onModelChange: (model: OpenAIModel) => void;
|
||||
}
|
||||
|
||||
export const ModelSelect: FC<Props> = ({ model, models, onModelChange }) => {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<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={selectedConversation?.model?.id || defaultModelId}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{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>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { FC, useEffect, useRef } from 'react';
|
||||
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { Plugin, PluginList } from '@/types/plugin';
|
||||
|
||||
interface Props {
|
||||
plugin: Plugin | null;
|
||||
onPluginChange: (plugin: Plugin) => void;
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLSelectElement>) => void;
|
||||
}
|
||||
|
||||
export const PluginSelect: FC<Props> = ({
|
||||
plugin,
|
||||
onPluginChange,
|
||||
onKeyDown,
|
||||
}) => {
|
||||
const { t } = useTranslation('chat');
|
||||
|
||||
const selectRef = useRef<HTMLSelectElement>(null);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLSelectElement>) => {
|
||||
const selectElement = selectRef.current;
|
||||
const optionCount = selectElement?.options.length || 0;
|
||||
|
||||
if (e.key === '/' && e.metaKey) {
|
||||
e.preventDefault();
|
||||
if (selectElement) {
|
||||
selectElement.selectedIndex =
|
||||
(selectElement.selectedIndex + 1) % optionCount;
|
||||
selectElement.dispatchEvent(new Event('change'));
|
||||
}
|
||||
} else if (e.key === '/' && e.shiftKey && e.metaKey) {
|
||||
e.preventDefault();
|
||||
if (selectElement) {
|
||||
selectElement.selectedIndex =
|
||||
(selectElement.selectedIndex - 1 + optionCount) % optionCount;
|
||||
selectElement.dispatchEvent(new Event('change'));
|
||||
}
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (selectElement) {
|
||||
selectElement.dispatchEvent(new Event('change'));
|
||||
}
|
||||
|
||||
onPluginChange(
|
||||
PluginList.find(
|
||||
(plugin) =>
|
||||
plugin.name === selectElement?.selectedOptions[0].innerText,
|
||||
) as Plugin,
|
||||
);
|
||||
} else {
|
||||
onKeyDown(e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (selectRef.current) {
|
||||
selectRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-1 w-full rounded 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,
|
||||
);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
handleKeyDown(e);
|
||||
}}
|
||||
>
|
||||
<option
|
||||
key="chatgpt"
|
||||
value="chatgpt"
|
||||
className="dark:bg-[#343541] dark:text-white"
|
||||
>
|
||||
ChatGPT
|
||||
</option>
|
||||
|
||||
{PluginList.map((plugin) => (
|
||||
<option
|
||||
key={plugin.id}
|
||||
value={plugin.id}
|
||||
className="dark:bg-[#343541] dark:text-white"
|
||||
>
|
||||
{plugin.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
import { FC, MutableRefObject } from 'react';
|
||||
|
||||
import { Prompt } from '@/types/prompt';
|
||||
|
||||
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,25 +1,20 @@
|
||||
import { IconRefresh } from '@tabler/icons-react';
|
||||
import { FC } from 'react';
|
||||
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { IconRefresh } from "@tabler/icons-react";
|
||||
import { FC } from "react";
|
||||
|
||||
interface Props {
|
||||
onRegenerate: () => void;
|
||||
}
|
||||
|
||||
export const Regenerate: FC<Props> = ({ onRegenerate }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
<button
|
||||
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"
|
||||
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"
|
||||
onClick={onRegenerate}
|
||||
>
|
||||
<IconRefresh />
|
||||
<div>{t('Regenerate response')}</div>
|
||||
<IconRefresh className="mr-2" />
|
||||
<div>Regenerate response</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,167 +1,36 @@
|
||||
import {
|
||||
FC,
|
||||
KeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { DEFAULT_SYSTEM_PROMPT } from '@/utils/app/const';
|
||||
|
||||
import { Conversation } from '@/types/chat';
|
||||
import { Prompt } from '@/types/prompt';
|
||||
|
||||
import { PromptList } from './PromptList';
|
||||
import { VariableModal } from './VariableModal';
|
||||
import { Conversation } from "@/types";
|
||||
import { DEFAULT_SYSTEM_PROMPT } from "@/utils/app/const";
|
||||
import { FC, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
conversation: Conversation;
|
||||
prompts: Prompt[];
|
||||
onChangePrompt: (prompt: string) => void;
|
||||
}
|
||||
|
||||
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);
|
||||
export const SystemPrompt: FC<Props> = ({ conversation, onChangePrompt }) => {
|
||||
const [value, setValue] = useState<string>("");
|
||||
|
||||
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 = conversation.model.maxLength;
|
||||
const maxLength = 4000;
|
||||
|
||||
if (value.length > maxLength) {
|
||||
alert(
|
||||
t(
|
||||
`Prompt limit is {{maxLength}} characters. You have entered {{valueLength}} characters.`,
|
||||
{ maxLength, valueLength: value.length },
|
||||
),
|
||||
);
|
||||
alert(`Prompt limit is ${maxLength} characters`);
|
||||
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]);
|
||||
@@ -174,70 +43,23 @@ export const SystemPrompt: FC<Props> = ({
|
||||
}
|
||||
}, [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="mb-2 text-left text-neutral-700 dark:text-neutral-400">
|
||||
{t('System Prompt')}
|
||||
</label>
|
||||
<label className="text-left dark:text-neutral-400 text-neutral-700 mb-2">System Prompt</label>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
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"
|
||||
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"
|
||||
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={
|
||||
t(`Enter a prompt or type "/" to select a prompt...`) || ''
|
||||
}
|
||||
value={t(value) || ''}
|
||||
placeholder="Enter a prompt"
|
||||
value={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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { FC, useContext, useState } from 'react';
|
||||
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { DEFAULT_TEMPERATURE } from '@/utils/app/const';
|
||||
|
||||
import HomeContext from '@/pages/api/home/home.context';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
onChangeTemperature: (temperature: number) => void;
|
||||
}
|
||||
|
||||
export const TemperatureSlider: FC<Props> = ({
|
||||
label,
|
||||
onChangeTemperature,
|
||||
}) => {
|
||||
const {
|
||||
state: { conversations },
|
||||
} = useContext(HomeContext);
|
||||
const lastConversation = conversations[conversations.length - 1];
|
||||
const [temperature, setTemperature] = useState(
|
||||
lastConversation?.temperature ?? DEFAULT_TEMPERATURE,
|
||||
);
|
||||
const { t } = useTranslation('chat');
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = parseFloat(event.target.value);
|
||||
setTemperature(newValue);
|
||||
onChangeTemperature(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<label className="mb-2 text-left text-neutral-700 dark:text-neutral-400">
|
||||
{label}
|
||||
</label>
|
||||
<span className="text-[12px] text-black/50 dark:text-white/50 text-sm">
|
||||
{t(
|
||||
'Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic.',
|
||||
)}
|
||||
</span>
|
||||
<span className="mt-2 mb-1 text-center text-neutral-900 dark:text-neutral-100">
|
||||
{temperature.toFixed(1)}
|
||||
</span>
|
||||
<input
|
||||
className="cursor-pointer"
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
value={temperature}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<ul className="w mt-2 pb-8 flex justify-between px-[24px] text-neutral-900 dark:text-neutral-100">
|
||||
<li className="relative flex justify-center">
|
||||
<span className="absolute">{t('Precise')}</span>
|
||||
</li>
|
||||
<li className="relative flex justify-center">
|
||||
<span className="absolute">{t('Neutral')}</span>
|
||||
</li>
|
||||
<li className="relative flex justify-center">
|
||||
<span className="absolute">{t('Creative')}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,124 +0,0 @@
|
||||
import { FC, KeyboardEvent, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Prompt } from '@/types/prompt';
|
||||
|
||||
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-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>
|
||||
);
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Dispatch, createContext } from 'react';
|
||||
|
||||
import { ActionType } from '@/hooks/useCreateReducer';
|
||||
|
||||
import { Conversation } from '@/types/chat';
|
||||
import { SupportedExportFormats } from '@/types/export';
|
||||
import { PluginKey } from '@/types/plugin';
|
||||
|
||||
import { ChatbarInitialState } from './Chatbar.state';
|
||||
|
||||
export interface ChatbarContextProps {
|
||||
state: ChatbarInitialState;
|
||||
dispatch: Dispatch<ActionType<ChatbarInitialState>>;
|
||||
handleDeleteConversation: (conversation: Conversation) => void;
|
||||
handleClearConversations: () => void;
|
||||
handleExportData: () => void;
|
||||
handleImportConversations: (data: SupportedExportFormats) => void;
|
||||
handlePluginKeyChange: (pluginKey: PluginKey) => void;
|
||||
handleClearPluginKey: (pluginKey: PluginKey) => void;
|
||||
handleApiKeyChange: (apiKey: string) => void;
|
||||
}
|
||||
|
||||
const ChatbarContext = createContext<ChatbarContextProps>(undefined!);
|
||||
|
||||
export default ChatbarContext;
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Conversation } from '@/types/chat';
|
||||
|
||||
export interface ChatbarInitialState {
|
||||
searchTerm: string;
|
||||
filteredConversations: Conversation[];
|
||||
}
|
||||
|
||||
export const initialState: ChatbarInitialState = {
|
||||
searchTerm: '',
|
||||
filteredConversations: [],
|
||||
};
|
||||
@@ -1,239 +0,0 @@
|
||||
import { useCallback, useContext, useEffect } from 'react';
|
||||
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { useCreateReducer } from '@/hooks/useCreateReducer';
|
||||
|
||||
import { DEFAULT_SYSTEM_PROMPT, DEFAULT_TEMPERATURE } from '@/utils/app/const';
|
||||
import { saveConversation, saveConversations } from '@/utils/app/conversation';
|
||||
import { saveFolders } from '@/utils/app/folders';
|
||||
import { exportData, importData } from '@/utils/app/importExport';
|
||||
|
||||
import { Conversation } from '@/types/chat';
|
||||
import { LatestExportFormat, SupportedExportFormats } from '@/types/export';
|
||||
import { OpenAIModels } from '@/types/openai';
|
||||
import { PluginKey } from '@/types/plugin';
|
||||
|
||||
import HomeContext from '@/pages/api/home/home.context';
|
||||
|
||||
import { ChatFolders } from './components/ChatFolders';
|
||||
import { ChatbarSettings } from './components/ChatbarSettings';
|
||||
import { Conversations } from './components/Conversations';
|
||||
|
||||
import Sidebar from '../Sidebar';
|
||||
import ChatbarContext from './Chatbar.context';
|
||||
import { ChatbarInitialState, initialState } from './Chatbar.state';
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export const Chatbar = () => {
|
||||
const { t } = useTranslation('sidebar');
|
||||
|
||||
const chatBarContextValue = useCreateReducer<ChatbarInitialState>({
|
||||
initialState,
|
||||
});
|
||||
|
||||
const {
|
||||
state: { conversations, showChatbar, defaultModelId, folders, pluginKeys },
|
||||
dispatch: homeDispatch,
|
||||
handleCreateFolder,
|
||||
handleNewConversation,
|
||||
handleUpdateConversation,
|
||||
} = useContext(HomeContext);
|
||||
|
||||
const {
|
||||
state: { searchTerm, filteredConversations },
|
||||
dispatch: chatDispatch,
|
||||
} = chatBarContextValue;
|
||||
|
||||
const handleApiKeyChange = useCallback(
|
||||
(apiKey: string) => {
|
||||
homeDispatch({ field: 'apiKey', value: apiKey });
|
||||
|
||||
localStorage.setItem('apiKey', apiKey);
|
||||
},
|
||||
[homeDispatch],
|
||||
);
|
||||
|
||||
const handlePluginKeyChange = (pluginKey: PluginKey) => {
|
||||
if (pluginKeys.some((key) => key.pluginId === pluginKey.pluginId)) {
|
||||
const updatedPluginKeys = pluginKeys.map((key) => {
|
||||
if (key.pluginId === pluginKey.pluginId) {
|
||||
return pluginKey;
|
||||
}
|
||||
|
||||
return key;
|
||||
});
|
||||
|
||||
homeDispatch({ field: 'pluginKeys', value: updatedPluginKeys });
|
||||
|
||||
localStorage.setItem('pluginKeys', JSON.stringify(updatedPluginKeys));
|
||||
} else {
|
||||
homeDispatch({ field: 'pluginKeys', value: [...pluginKeys, pluginKey] });
|
||||
|
||||
localStorage.setItem(
|
||||
'pluginKeys',
|
||||
JSON.stringify([...pluginKeys, pluginKey]),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearPluginKey = (pluginKey: PluginKey) => {
|
||||
const updatedPluginKeys = pluginKeys.filter(
|
||||
(key) => key.pluginId !== pluginKey.pluginId,
|
||||
);
|
||||
|
||||
if (updatedPluginKeys.length === 0) {
|
||||
homeDispatch({ field: 'pluginKeys', value: [] });
|
||||
localStorage.removeItem('pluginKeys');
|
||||
return;
|
||||
}
|
||||
|
||||
homeDispatch({ field: 'pluginKeys', value: updatedPluginKeys });
|
||||
|
||||
localStorage.setItem('pluginKeys', JSON.stringify(updatedPluginKeys));
|
||||
};
|
||||
|
||||
const handleExportData = () => {
|
||||
exportData();
|
||||
};
|
||||
|
||||
const handleImportConversations = (data: SupportedExportFormats) => {
|
||||
const { history, folders, prompts }: LatestExportFormat = importData(data);
|
||||
homeDispatch({ field: 'conversations', value: history });
|
||||
homeDispatch({
|
||||
field: 'selectedConversation',
|
||||
value: history[history.length - 1],
|
||||
});
|
||||
homeDispatch({ field: 'folders', value: folders });
|
||||
homeDispatch({ field: 'prompts', value: prompts });
|
||||
};
|
||||
|
||||
const handleClearConversations = () => {
|
||||
defaultModelId &&
|
||||
homeDispatch({
|
||||
field: 'selectedConversation',
|
||||
value: {
|
||||
id: uuidv4(),
|
||||
name: 'New conversation',
|
||||
messages: [],
|
||||
model: OpenAIModels[defaultModelId],
|
||||
prompt: DEFAULT_SYSTEM_PROMPT,
|
||||
temperature: DEFAULT_TEMPERATURE,
|
||||
folderId: null,
|
||||
},
|
||||
});
|
||||
|
||||
homeDispatch({ field: 'conversations', value: [] });
|
||||
|
||||
localStorage.removeItem('conversationHistory');
|
||||
localStorage.removeItem('selectedConversation');
|
||||
|
||||
const updatedFolders = folders.filter((f) => f.type !== 'chat');
|
||||
|
||||
homeDispatch({ field: 'folders', value: updatedFolders });
|
||||
saveFolders(updatedFolders);
|
||||
};
|
||||
|
||||
const handleDeleteConversation = (conversation: Conversation) => {
|
||||
const updatedConversations = conversations.filter(
|
||||
(c) => c.id !== conversation.id,
|
||||
);
|
||||
|
||||
homeDispatch({ field: 'conversations', value: updatedConversations });
|
||||
chatDispatch({ field: 'searchTerm', value: '' });
|
||||
saveConversations(updatedConversations);
|
||||
|
||||
if (updatedConversations.length > 0) {
|
||||
homeDispatch({
|
||||
field: 'selectedConversation',
|
||||
value: updatedConversations[updatedConversations.length - 1],
|
||||
});
|
||||
|
||||
saveConversation(updatedConversations[updatedConversations.length - 1]);
|
||||
} else {
|
||||
defaultModelId &&
|
||||
homeDispatch({
|
||||
field: 'selectedConversation',
|
||||
value: {
|
||||
id: uuidv4(),
|
||||
name: 'New conversation',
|
||||
messages: [],
|
||||
model: OpenAIModels[defaultModelId],
|
||||
prompt: DEFAULT_SYSTEM_PROMPT,
|
||||
temperature: DEFAULT_TEMPERATURE,
|
||||
folderId: null,
|
||||
},
|
||||
});
|
||||
|
||||
localStorage.removeItem('selectedConversation');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleChatbar = () => {
|
||||
homeDispatch({ field: 'showChatbar', value: !showChatbar });
|
||||
localStorage.setItem('showChatbar', JSON.stringify(!showChatbar));
|
||||
};
|
||||
|
||||
const handleDrop = (e: any) => {
|
||||
if (e.dataTransfer) {
|
||||
const conversation = JSON.parse(e.dataTransfer.getData('conversation'));
|
||||
handleUpdateConversation(conversation, { key: 'folderId', value: 0 });
|
||||
chatDispatch({ field: 'searchTerm', value: '' });
|
||||
e.target.style.background = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (searchTerm) {
|
||||
chatDispatch({
|
||||
field: 'filteredConversations',
|
||||
value: conversations.filter((conversation) => {
|
||||
const searchable =
|
||||
conversation.name.toLocaleLowerCase() +
|
||||
' ' +
|
||||
conversation.messages.map((message) => message.content).join(' ');
|
||||
return searchable.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
chatDispatch({
|
||||
field: 'filteredConversations',
|
||||
value: conversations,
|
||||
});
|
||||
}
|
||||
}, [searchTerm, conversations]);
|
||||
|
||||
return (
|
||||
<ChatbarContext.Provider
|
||||
value={{
|
||||
...chatBarContextValue,
|
||||
handleDeleteConversation,
|
||||
handleClearConversations,
|
||||
handleImportConversations,
|
||||
handleExportData,
|
||||
handlePluginKeyChange,
|
||||
handleClearPluginKey,
|
||||
handleApiKeyChange,
|
||||
}}
|
||||
>
|
||||
<Sidebar<Conversation>
|
||||
side={'left'}
|
||||
isOpen={showChatbar}
|
||||
addItemButtonTitle={t('New chat')}
|
||||
itemComponent={<Conversations conversations={filteredConversations} />}
|
||||
folderComponent={<ChatFolders searchTerm={searchTerm} />}
|
||||
items={filteredConversations}
|
||||
searchTerm={searchTerm}
|
||||
handleSearchTerm={(searchTerm: string) =>
|
||||
chatDispatch({ field: 'searchTerm', value: searchTerm })
|
||||
}
|
||||
toggleOpen={handleToggleChatbar}
|
||||
handleCreateItem={handleNewConversation}
|
||||
handleCreateFolder={() => handleCreateFolder(t('New folder'), 'chat')}
|
||||
handleDrop={handleDrop}
|
||||
footerComponent={<ChatbarSettings />}
|
||||
/>
|
||||
</ChatbarContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,63 +0,0 @@
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { FolderInterface } from '@/types/folder';
|
||||
|
||||
import HomeContext from '@/pages/api/home/home.context';
|
||||
|
||||
import Folder from '@/components/Folder';
|
||||
|
||||
import { ConversationComponent } from './Conversation';
|
||||
|
||||
interface Props {
|
||||
searchTerm: string;
|
||||
}
|
||||
|
||||
export const ChatFolders = ({ searchTerm }: Props) => {
|
||||
const {
|
||||
state: { folders, conversations },
|
||||
handleUpdateConversation,
|
||||
} = useContext(HomeContext);
|
||||
|
||||
const handleDrop = (e: any, folder: FolderInterface) => {
|
||||
if (e.dataTransfer) {
|
||||
const conversation = JSON.parse(e.dataTransfer.getData('conversation'));
|
||||
handleUpdateConversation(conversation, {
|
||||
key: 'folderId',
|
||||
value: folder.id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const ChatFolders = (currentFolder: FolderInterface) => {
|
||||
return (
|
||||
conversations &&
|
||||
conversations
|
||||
.filter((conversation) => conversation.folderId)
|
||||
.map((conversation, index) => {
|
||||
if (conversation.folderId === currentFolder.id) {
|
||||
return (
|
||||
<div key={index} className="ml-5 gap-2 border-l pl-2">
|
||||
<ConversationComponent conversation={conversation} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col pt-2">
|
||||
{folders
|
||||
.filter((folder) => folder.type === 'chat')
|
||||
.map((folder, index) => (
|
||||
<Folder
|
||||
key={index}
|
||||
searchTerm={searchTerm}
|
||||
currentFolder={folder}
|
||||
handleDrop={handleDrop}
|
||||
folderComponent={ChatFolders(folder)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,73 +0,0 @@
|
||||
import { IconFileExport, IconSettings } from '@tabler/icons-react';
|
||||
import { useContext, useState } from 'react';
|
||||
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import HomeContext from '@/pages/api/home/home.context';
|
||||
|
||||
import { SettingDialog } from '@/components/Settings/SettingDialog';
|
||||
|
||||
import { Import } from '../../Settings/Import';
|
||||
import { Key } from '../../Settings/Key';
|
||||
import { SidebarButton } from '../../Sidebar/SidebarButton';
|
||||
import ChatbarContext from '../Chatbar.context';
|
||||
import { ClearConversations } from './ClearConversations';
|
||||
import { PluginKeys } from './PluginKeys';
|
||||
|
||||
export const ChatbarSettings = () => {
|
||||
const { t } = useTranslation('sidebar');
|
||||
const [isSettingDialogOpen, setIsSettingDialog] = useState<boolean>(false);
|
||||
|
||||
const {
|
||||
state: {
|
||||
apiKey,
|
||||
lightMode,
|
||||
serverSideApiKeyIsSet,
|
||||
serverSidePluginKeysSet,
|
||||
conversations,
|
||||
},
|
||||
dispatch: homeDispatch,
|
||||
} = useContext(HomeContext);
|
||||
|
||||
const {
|
||||
handleClearConversations,
|
||||
handleImportConversations,
|
||||
handleExportData,
|
||||
handleApiKeyChange,
|
||||
} = useContext(ChatbarContext);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-1 border-t border-white/20 pt-1 text-sm">
|
||||
{conversations.length > 0 ? (
|
||||
<ClearConversations onClearConversations={handleClearConversations} />
|
||||
) : null}
|
||||
|
||||
<Import onImport={handleImportConversations} />
|
||||
|
||||
<SidebarButton
|
||||
text={t('Export data')}
|
||||
icon={<IconFileExport size={18} />}
|
||||
onClick={() => handleExportData()}
|
||||
/>
|
||||
|
||||
<SidebarButton
|
||||
text={t('Settings')}
|
||||
icon={<IconSettings size={18} />}
|
||||
onClick={() => setIsSettingDialog(true)}
|
||||
/>
|
||||
|
||||
{!serverSideApiKeyIsSet ? (
|
||||
<Key apiKey={apiKey} onApiKeyChange={handleApiKeyChange} />
|
||||
) : null}
|
||||
|
||||
{!serverSidePluginKeysSet ? <PluginKeys /> : null}
|
||||
|
||||
<SettingDialog
|
||||
open={isSettingDialogOpen}
|
||||
onClose={() => {
|
||||
setIsSettingDialog(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,168 +0,0 @@
|
||||
import {
|
||||
IconCheck,
|
||||
IconMessage,
|
||||
IconPencil,
|
||||
IconTrash,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
DragEvent,
|
||||
KeyboardEvent,
|
||||
MouseEventHandler,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { Conversation } from '@/types/chat';
|
||||
|
||||
import HomeContext from '@/pages/api/home/home.context';
|
||||
|
||||
import SidebarActionButton from '@/components/Buttons/SidebarActionButton';
|
||||
import ChatbarContext from '@/components/Chatbar/Chatbar.context';
|
||||
|
||||
interface Props {
|
||||
conversation: Conversation;
|
||||
}
|
||||
|
||||
export const ConversationComponent = ({ conversation }: Props) => {
|
||||
const {
|
||||
state: { selectedConversation, messageIsStreaming },
|
||||
handleSelectConversation,
|
||||
handleUpdateConversation,
|
||||
} = useContext(HomeContext);
|
||||
|
||||
const { handleDeleteConversation } = useContext(ChatbarContext);
|
||||
|
||||
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();
|
||||
selectedConversation && 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) {
|
||||
handleUpdateConversation(conversation, {
|
||||
key: 'name',
|
||||
value: renameValue,
|
||||
});
|
||||
setRenameValue('');
|
||||
setIsRenaming(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm: MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
e.stopPropagation();
|
||||
if (isDeleting) {
|
||||
handleDeleteConversation(conversation);
|
||||
} else if (isRenaming) {
|
||||
handleRename(conversation);
|
||||
}
|
||||
setIsDeleting(false);
|
||||
setIsRenaming(false);
|
||||
};
|
||||
|
||||
const handleCancel: MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
e.stopPropagation();
|
||||
setIsDeleting(false);
|
||||
setIsRenaming(false);
|
||||
};
|
||||
|
||||
const handleOpenRenameModal: MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
e.stopPropagation();
|
||||
setIsRenaming(true);
|
||||
selectedConversation && setRenameValue(selectedConversation.name);
|
||||
};
|
||||
const handleOpenDeleteModal: MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
e.stopPropagation();
|
||||
setIsDeleting(true);
|
||||
};
|
||||
|
||||
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 rounded-lg bg-[#343541]/90 p-3">
|
||||
<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 ${
|
||||
messageIsStreaming ? 'disabled:cursor-not-allowed' : ''
|
||||
} ${
|
||||
selectedConversation?.id === conversation.id
|
||||
? 'bg-[#343541]/90'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() => handleSelectConversation(conversation)}
|
||||
disabled={messageIsStreaming}
|
||||
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">
|
||||
<SidebarActionButton handleClick={handleConfirm}>
|
||||
<IconCheck size={18} />
|
||||
</SidebarActionButton>
|
||||
<SidebarActionButton handleClick={handleCancel}>
|
||||
<IconX size={18} />
|
||||
</SidebarActionButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedConversation?.id === conversation.id &&
|
||||
!isDeleting &&
|
||||
!isRenaming && (
|
||||
<div className="absolute right-1 z-10 flex text-gray-300">
|
||||
<SidebarActionButton handleClick={handleOpenRenameModal}>
|
||||
<IconPencil size={18} />
|
||||
</SidebarActionButton>
|
||||
<SidebarActionButton handleClick={handleOpenDeleteModal}>
|
||||
<IconTrash size={18} />
|
||||
</SidebarActionButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Conversation } from '@/types/chat';
|
||||
|
||||
import { ConversationComponent } from './Conversation';
|
||||
|
||||
interface Props {
|
||||
conversations: Conversation[];
|
||||
}
|
||||
|
||||
export const Conversations = ({ conversations }: Props) => {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
{conversations
|
||||
.filter((conversation) => !conversation.folderId)
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((conversation, index) => (
|
||||
<ConversationComponent key={index} conversation={conversation} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,235 +0,0 @@
|
||||
import { IconKey } from '@tabler/icons-react';
|
||||
import { KeyboardEvent, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { PluginID, PluginKey } from '@/types/plugin';
|
||||
|
||||
import HomeContext from '@/pages/api/home/home.context';
|
||||
|
||||
import { SidebarButton } from '@/components/Sidebar/SidebarButton';
|
||||
|
||||
import ChatbarContext from '../Chatbar.context';
|
||||
|
||||
export const PluginKeys = () => {
|
||||
const { t } = useTranslation('sidebar');
|
||||
|
||||
const {
|
||||
state: { pluginKeys },
|
||||
} = useContext(HomeContext);
|
||||
|
||||
const { handlePluginKeyChange, handleClearPluginKey } =
|
||||
useContext(ChatbarContext);
|
||||
|
||||
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-hidden">
|
||||
<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-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-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;
|
||||
}),
|
||||
};
|
||||
|
||||
handlePluginKeyChange(updatedPluginKey);
|
||||
}
|
||||
} else {
|
||||
const newPluginKey: PluginKey = {
|
||||
pluginId: PluginID.GOOGLE_SEARCH,
|
||||
requiredKeys: [
|
||||
{
|
||||
key: 'GOOGLE_API_KEY',
|
||||
value: e.target.value,
|
||||
},
|
||||
{
|
||||
key: 'GOOGLE_CSE_ID',
|
||||
value: '',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
handlePluginKeyChange(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;
|
||||
}),
|
||||
};
|
||||
|
||||
handlePluginKeyChange(updatedPluginKey);
|
||||
}
|
||||
} else {
|
||||
const newPluginKey: PluginKey = {
|
||||
pluginId: PluginID.GOOGLE_SEARCH,
|
||||
requiredKeys: [
|
||||
{
|
||||
key: 'GOOGLE_API_KEY',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
key: 'GOOGLE_CSE_ID',
|
||||
value: e.target.value,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
handlePluginKeyChange(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) {
|
||||
handleClearPluginKey(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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,192 +0,0 @@
|
||||
import {
|
||||
IconCaretDown,
|
||||
IconCaretRight,
|
||||
IconCheck,
|
||||
IconPencil,
|
||||
IconTrash,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
KeyboardEvent,
|
||||
ReactElement,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { FolderInterface } from '@/types/folder';
|
||||
|
||||
import HomeContext from '@/pages/api/home/home.context';
|
||||
|
||||
import SidebarActionButton from '@/components/Buttons/SidebarActionButton';
|
||||
|
||||
interface Props {
|
||||
currentFolder: FolderInterface;
|
||||
searchTerm: string;
|
||||
handleDrop: (e: any, folder: FolderInterface) => void;
|
||||
folderComponent: (ReactElement | undefined)[];
|
||||
}
|
||||
|
||||
const Folder = ({
|
||||
currentFolder,
|
||||
searchTerm,
|
||||
handleDrop,
|
||||
folderComponent,
|
||||
}: Props) => {
|
||||
const { handleDeleteFolder, handleUpdateFolder } = useContext(HomeContext);
|
||||
|
||||
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 = () => {
|
||||
handleUpdateFolder(currentFolder.id, renameValue);
|
||||
setRenameValue('');
|
||||
setIsRenaming(false);
|
||||
};
|
||||
|
||||
const dropHandler = (e: any) => {
|
||||
if (e.dataTransfer) {
|
||||
setIsOpen(true);
|
||||
|
||||
handleDrop(e, currentFolder);
|
||||
|
||||
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) => dropHandler(e)}
|
||||
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">
|
||||
<SidebarActionButton
|
||||
handleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (isDeleting) {
|
||||
handleDeleteFolder(currentFolder.id);
|
||||
} else if (isRenaming) {
|
||||
handleRename();
|
||||
}
|
||||
|
||||
setIsDeleting(false);
|
||||
setIsRenaming(false);
|
||||
}}
|
||||
>
|
||||
<IconCheck size={18} />
|
||||
</SidebarActionButton>
|
||||
<SidebarActionButton
|
||||
handleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDeleting(false);
|
||||
setIsRenaming(false);
|
||||
}}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</SidebarActionButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isDeleting && !isRenaming && (
|
||||
<div className="absolute right-1 z-10 flex text-gray-300">
|
||||
<SidebarActionButton
|
||||
handleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsRenaming(true);
|
||||
setRenameValue(currentFolder.name);
|
||||
}}
|
||||
>
|
||||
<IconPencil size={18} />
|
||||
</SidebarActionButton>
|
||||
<SidebarActionButton
|
||||
handleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDeleting(true);
|
||||
}}
|
||||
>
|
||||
<IconTrash size={18} />
|
||||
</SidebarActionButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOpen ? folderComponent : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Folder;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './Folder';
|
||||
@@ -1,94 +1,41 @@
|
||||
import { IconCheck, IconClipboard, IconDownload } from '@tabler/icons-react';
|
||||
import { FC, memo, useState } from 'react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
|
||||
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import {
|
||||
generateRandomString,
|
||||
programmingLanguages,
|
||||
} from '@/utils/app/codeblock';
|
||||
import { FC, useState } from "react";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneDark, oneLight } from "react-syntax-highlighter/dist/cjs/styles/prism";
|
||||
|
||||
interface Props {
|
||||
language: string;
|
||||
value: string;
|
||||
lightMode: "light" | "dark";
|
||||
}
|
||||
|
||||
export const CodeBlock: FC<Props> = memo(({ language, value }) => {
|
||||
const { t } = useTranslation('markdown');
|
||||
const [isCopied, setIsCopied] = useState<Boolean>(false);
|
||||
export const CodeBlock: FC<Props> = ({ language, value, lightMode }) => {
|
||||
const [buttonText, setButtonText] = useState("Copy code");
|
||||
|
||||
const copyToClipboard = () => {
|
||||
if (!navigator.clipboard || !navigator.clipboard.writeText) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(value).then(() => {
|
||||
setIsCopied(true);
|
||||
setButtonText("Copied!");
|
||||
|
||||
setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
setButtonText("Copy code");
|
||||
}, 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="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>
|
||||
|
||||
<div className="relative text-[16px] pt-2">
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={oneDark}
|
||||
customStyle={{ margin: 0 }}
|
||||
style={lightMode === "light" ? oneLight : oneDark}
|
||||
>
|
||||
{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';
|
||||
};
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import { FC, memo } from 'react';
|
||||
import ReactMarkdown, { Options } from 'react-markdown';
|
||||
|
||||
export const MemoizedReactMarkdown: FC<Options> = memo(ReactMarkdown);
|
||||
@@ -1,29 +1,23 @@
|
||||
import { IconPlus } from '@tabler/icons-react';
|
||||
import { FC } from 'react';
|
||||
|
||||
import { Conversation } from '@/types/chat';
|
||||
import { Conversation } from "@/types";
|
||||
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 (
|
||||
<nav className="flex w-full justify-between bg-[#202123] py-3 px-4">
|
||||
<div className="flex justify-between bg-[#202123] py-3 px-4 w-full">
|
||||
<div className="mr-4"></div>
|
||||
|
||||
<div className="max-w-[240px] overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{selectedConversation.name}
|
||||
</div>
|
||||
<div className="max-w-[240px] whitespace-nowrap overflow-hidden text-ellipsis">{selectedConversation.name}</div>
|
||||
|
||||
<IconPlus
|
||||
className="cursor-pointer hover:text-neutral-400 mr-8"
|
||||
className="cursor-pointer hover:text-neutral-400"
|
||||
onClick={onNewConversation}
|
||||
/>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Dispatch, createContext } from 'react';
|
||||
|
||||
import { ActionType } from '@/hooks/useCreateReducer';
|
||||
|
||||
import { Prompt } from '@/types/prompt';
|
||||
|
||||
import { PromptbarInitialState } from './Promptbar.state';
|
||||
|
||||
export interface PromptbarContextProps {
|
||||
state: PromptbarInitialState;
|
||||
dispatch: Dispatch<ActionType<PromptbarInitialState>>;
|
||||
handleCreatePrompt: () => void;
|
||||
handleDeletePrompt: (prompt: Prompt) => void;
|
||||
handleUpdatePrompt: (prompt: Prompt) => void;
|
||||
}
|
||||
|
||||
const PromptbarContext = createContext<PromptbarContextProps>(undefined!);
|
||||
|
||||
export default PromptbarContext;
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Prompt } from '@/types/prompt';
|
||||
|
||||
export interface PromptbarInitialState {
|
||||
searchTerm: string;
|
||||
filteredPrompts: Prompt[];
|
||||
}
|
||||
|
||||
export const initialState: PromptbarInitialState = {
|
||||
searchTerm: '',
|
||||
filteredPrompts: [],
|
||||
};
|
||||
@@ -1,152 +0,0 @@
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useCreateReducer } from '@/hooks/useCreateReducer';
|
||||
|
||||
import { savePrompts } from '@/utils/app/prompts';
|
||||
|
||||
import { OpenAIModels } from '@/types/openai';
|
||||
import { Prompt } from '@/types/prompt';
|
||||
|
||||
import HomeContext from '@/pages/api/home/home.context';
|
||||
|
||||
import { PromptFolders } from './components/PromptFolders';
|
||||
import { PromptbarSettings } from './components/PromptbarSettings';
|
||||
import { Prompts } from './components/Prompts';
|
||||
|
||||
import Sidebar from '../Sidebar';
|
||||
import PromptbarContext from './PromptBar.context';
|
||||
import { PromptbarInitialState, initialState } from './Promptbar.state';
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const Promptbar = () => {
|
||||
const { t } = useTranslation('promptbar');
|
||||
|
||||
const promptBarContextValue = useCreateReducer<PromptbarInitialState>({
|
||||
initialState,
|
||||
});
|
||||
|
||||
const {
|
||||
state: { prompts, defaultModelId, showPromptbar },
|
||||
dispatch: homeDispatch,
|
||||
handleCreateFolder,
|
||||
} = useContext(HomeContext);
|
||||
|
||||
const {
|
||||
state: { searchTerm, filteredPrompts },
|
||||
dispatch: promptDispatch,
|
||||
} = promptBarContextValue;
|
||||
|
||||
const handleTogglePromptbar = () => {
|
||||
homeDispatch({ field: 'showPromptbar', value: !showPromptbar });
|
||||
localStorage.setItem('showPromptbar', JSON.stringify(!showPromptbar));
|
||||
};
|
||||
|
||||
const handleCreatePrompt = () => {
|
||||
if (defaultModelId) {
|
||||
const newPrompt: Prompt = {
|
||||
id: uuidv4(),
|
||||
name: `Prompt ${prompts.length + 1}`,
|
||||
description: '',
|
||||
content: '',
|
||||
model: OpenAIModels[defaultModelId],
|
||||
folderId: null,
|
||||
};
|
||||
|
||||
const updatedPrompts = [...prompts, newPrompt];
|
||||
|
||||
homeDispatch({ field: 'prompts', value: updatedPrompts });
|
||||
|
||||
savePrompts(updatedPrompts);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePrompt = (prompt: Prompt) => {
|
||||
const updatedPrompts = prompts.filter((p) => p.id !== prompt.id);
|
||||
|
||||
homeDispatch({ field: 'prompts', value: updatedPrompts });
|
||||
savePrompts(updatedPrompts);
|
||||
};
|
||||
|
||||
const handleUpdatePrompt = (prompt: Prompt) => {
|
||||
const updatedPrompts = prompts.map((p) => {
|
||||
if (p.id === prompt.id) {
|
||||
return prompt;
|
||||
}
|
||||
|
||||
return p;
|
||||
});
|
||||
homeDispatch({ field: 'prompts', value: updatedPrompts });
|
||||
|
||||
savePrompts(updatedPrompts);
|
||||
};
|
||||
|
||||
const handleDrop = (e: any) => {
|
||||
if (e.dataTransfer) {
|
||||
const prompt = JSON.parse(e.dataTransfer.getData('prompt'));
|
||||
|
||||
const updatedPrompt = {
|
||||
...prompt,
|
||||
folderId: e.target.dataset.folderId,
|
||||
};
|
||||
|
||||
handleUpdatePrompt(updatedPrompt);
|
||||
|
||||
e.target.style.background = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (searchTerm) {
|
||||
promptDispatch({
|
||||
field: 'filteredPrompts',
|
||||
value: prompts.filter((prompt) => {
|
||||
const searchable =
|
||||
prompt.name.toLowerCase() +
|
||||
' ' +
|
||||
prompt.description.toLowerCase() +
|
||||
' ' +
|
||||
prompt.content.toLowerCase();
|
||||
return searchable.includes(searchTerm.toLowerCase());
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
promptDispatch({ field: 'filteredPrompts', value: prompts });
|
||||
}
|
||||
}, [searchTerm, prompts]);
|
||||
|
||||
return (
|
||||
<PromptbarContext.Provider
|
||||
value={{
|
||||
...promptBarContextValue,
|
||||
handleCreatePrompt,
|
||||
handleDeletePrompt,
|
||||
handleUpdatePrompt,
|
||||
}}
|
||||
>
|
||||
<Sidebar<Prompt>
|
||||
side={'right'}
|
||||
isOpen={showPromptbar}
|
||||
addItemButtonTitle={t('New prompt')}
|
||||
itemComponent={
|
||||
<Prompts
|
||||
prompts={filteredPrompts.filter((prompt) => !prompt.folderId)}
|
||||
/>
|
||||
}
|
||||
folderComponent={<PromptFolders />}
|
||||
items={filteredPrompts}
|
||||
searchTerm={searchTerm}
|
||||
handleSearchTerm={(searchTerm: string) =>
|
||||
promptDispatch({ field: 'searchTerm', value: searchTerm })
|
||||
}
|
||||
toggleOpen={handleTogglePromptbar}
|
||||
handleCreateItem={handleCreatePrompt}
|
||||
handleCreateFolder={() => handleCreateFolder(t('New folder'), 'prompt')}
|
||||
handleDrop={handleDrop}
|
||||
/>
|
||||
</PromptbarContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Promptbar;
|
||||
@@ -1,130 +0,0 @@
|
||||
import {
|
||||
IconBulbFilled,
|
||||
IconCheck,
|
||||
IconTrash,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
DragEvent,
|
||||
MouseEventHandler,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { Prompt } from '@/types/prompt';
|
||||
|
||||
import SidebarActionButton from '@/components/Buttons/SidebarActionButton';
|
||||
|
||||
import PromptbarContext from '../PromptBar.context';
|
||||
import { PromptModal } from './PromptModal';
|
||||
|
||||
interface Props {
|
||||
prompt: Prompt;
|
||||
}
|
||||
|
||||
export const PromptComponent = ({ prompt }: Props) => {
|
||||
const {
|
||||
dispatch: promptDispatch,
|
||||
handleUpdatePrompt,
|
||||
handleDeletePrompt,
|
||||
} = useContext(PromptbarContext);
|
||||
|
||||
const [showModal, setShowModal] = useState<boolean>(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
|
||||
const handleUpdate = (prompt: Prompt) => {
|
||||
handleUpdatePrompt(prompt);
|
||||
promptDispatch({ field: 'searchTerm', value: '' });
|
||||
};
|
||||
|
||||
const handleDelete: MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (isDeleting) {
|
||||
handleDeletePrompt(prompt);
|
||||
promptDispatch({ field: 'searchTerm', value: '' });
|
||||
}
|
||||
|
||||
setIsDeleting(false);
|
||||
};
|
||||
|
||||
const handleCancelDelete: MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
e.stopPropagation();
|
||||
setIsDeleting(false);
|
||||
};
|
||||
|
||||
const handleOpenDeleteModal: MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
e.stopPropagation();
|
||||
setIsDeleting(true);
|
||||
};
|
||||
|
||||
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">
|
||||
<SidebarActionButton handleClick={handleDelete}>
|
||||
<IconCheck size={18} />
|
||||
</SidebarActionButton>
|
||||
|
||||
<SidebarActionButton handleClick={handleCancelDelete}>
|
||||
<IconX size={18} />
|
||||
</SidebarActionButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isDeleting && !isRenaming && (
|
||||
<div className="absolute right-1 z-10 flex text-gray-300">
|
||||
<SidebarActionButton handleClick={handleOpenDeleteModal}>
|
||||
<IconTrash size={18} />
|
||||
</SidebarActionButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showModal && (
|
||||
<PromptModal
|
||||
prompt={prompt}
|
||||
onClose={() => setShowModal(false)}
|
||||
onUpdatePrompt={handleUpdate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,63 +0,0 @@
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { FolderInterface } from '@/types/folder';
|
||||
|
||||
import HomeContext from '@/pages/api/home/home.context';
|
||||
|
||||
import Folder from '@/components/Folder';
|
||||
import { PromptComponent } from '@/components/Promptbar/components/Prompt';
|
||||
|
||||
import PromptbarContext from '../PromptBar.context';
|
||||
|
||||
export const PromptFolders = () => {
|
||||
const {
|
||||
state: { folders },
|
||||
} = useContext(HomeContext);
|
||||
|
||||
const {
|
||||
state: { searchTerm, filteredPrompts },
|
||||
handleUpdatePrompt,
|
||||
} = useContext(PromptbarContext);
|
||||
|
||||
const handleDrop = (e: any, folder: FolderInterface) => {
|
||||
if (e.dataTransfer) {
|
||||
const prompt = JSON.parse(e.dataTransfer.getData('prompt'));
|
||||
|
||||
const updatedPrompt = {
|
||||
...prompt,
|
||||
folderId: folder.id,
|
||||
};
|
||||
|
||||
handleUpdatePrompt(updatedPrompt);
|
||||
}
|
||||
};
|
||||
|
||||
const PromptFolders = (currentFolder: FolderInterface) =>
|
||||
filteredPrompts
|
||||
.filter((p) => p.folderId)
|
||||
.map((prompt, index) => {
|
||||
if (prompt.folderId === currentFolder.id) {
|
||||
return (
|
||||
<div key={index} className="ml-5 gap-2 border-l pl-2">
|
||||
<PromptComponent prompt={prompt} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col pt-2">
|
||||
{folders
|
||||
.filter((folder) => folder.type === 'prompt')
|
||||
.map((folder, index) => (
|
||||
<Folder
|
||||
key={index}
|
||||
searchTerm={searchTerm}
|
||||
currentFolder={folder}
|
||||
handleDrop={handleDrop}
|
||||
folderComponent={PromptFolders(folder)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,130 +0,0 @@
|
||||
import { FC, KeyboardEvent, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { Prompt } from '@/types/prompt';
|
||||
|
||||
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-50"
|
||||
onKeyDown={handleEnter}
|
||||
>
|
||||
<div className="fixed inset-0 z-10 overflow-hidden">
|
||||
<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-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="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>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
import { FC } from 'react';
|
||||
|
||||
interface Props {}
|
||||
|
||||
export const PromptbarSettings: FC<Props> = () => {
|
||||
return <div></div>;
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
import { FC } from 'react';
|
||||
|
||||
import { Prompt } from '@/types/prompt';
|
||||
|
||||
import { PromptComponent } from './Prompt';
|
||||
|
||||
interface Props {
|
||||
prompts: Prompt[];
|
||||
}
|
||||
|
||||
export const Prompts: FC<Props> = ({ prompts }) => {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
{prompts
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((prompt, index) => (
|
||||
<PromptComponent key={index} prompt={prompt} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './Promptbar';
|
||||
@@ -1,43 +0,0 @@
|
||||
import { IconX } from '@tabler/icons-react';
|
||||
import { FC } from 'react';
|
||||
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
interface Props {
|
||||
placeholder: string;
|
||||
searchTerm: string;
|
||||
onSearch: (searchTerm: string) => void;
|
||||
}
|
||||
const Search: FC<Props> = ({ placeholder, searchTerm, onSearch }) => {
|
||||
const { t } = useTranslation('sidebar');
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onSearch(e.target.value);
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
onSearch('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
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={t(placeholder) || ''}
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
|
||||
{searchTerm && (
|
||||
<IconX
|
||||
className="absolute right-4 cursor-pointer text-neutral-300 hover:text-neutral-400"
|
||||
size={18}
|
||||
onClick={clearSearch}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Search;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './Search';
|
||||
@@ -1,51 +0,0 @@
|
||||
import { IconFileImport } from '@tabler/icons-react';
|
||||
import { FC } from 'react';
|
||||
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { SupportedExportFormats } from '@/types/export';
|
||||
|
||||
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,105 +0,0 @@
|
||||
import { FC, useContext, useEffect, useReducer, useRef } from 'react';
|
||||
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { useCreateReducer } from '@/hooks/useCreateReducer';
|
||||
|
||||
import { getSettings, saveSettings } from '@/utils/app/settings';
|
||||
|
||||
import { Settings } from '@/types/settings';
|
||||
|
||||
import HomeContext from '@/pages/api/home/home.context';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const SettingDialog: FC<Props> = ({ open, onClose }) => {
|
||||
const { t } = useTranslation('settings');
|
||||
const settings: Settings = getSettings();
|
||||
const { state, dispatch } = useCreateReducer<Settings>({
|
||||
initialState: settings,
|
||||
});
|
||||
const { dispatch: homeDispatch } = useContext(HomeContext);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
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]);
|
||||
|
||||
const handleSave = () => {
|
||||
homeDispatch({ field: 'lightMode', value: state.theme });
|
||||
saveSettings(state);
|
||||
};
|
||||
|
||||
// Render nothing if the dialog is not open.
|
||||
if (!open) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
// Render the dialog.
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50">
|
||||
<div className="fixed inset-0 z-10 overflow-hidden">
|
||||
<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-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="text-lg pb-4 font-bold text-black dark:text-neutral-200">
|
||||
{t('Settings')}
|
||||
</div>
|
||||
|
||||
<div className="text-sm font-bold mb-2 text-black dark:text-neutral-200">
|
||||
{t('Theme')}
|
||||
</div>
|
||||
|
||||
<select
|
||||
className="w-full cursor-pointer bg-transparent p-2 text-neutral-700 dark:text-neutral-200"
|
||||
value={state.theme}
|
||||
onChange={(event) =>
|
||||
dispatch({ field: 'theme', value: event.target.value })
|
||||
}
|
||||
>
|
||||
<option value="dark">{t('Dark mode')}</option>
|
||||
<option value="light">{t('Light mode')}</option>
|
||||
</select>
|
||||
|
||||
<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={() => {
|
||||
handleSave();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{t('Save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+9
-16
@@ -1,9 +1,6 @@
|
||||
import { IconCheck, IconTrash, IconX } from '@tabler/icons-react';
|
||||
import { FC, useState } from 'react';
|
||||
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { SidebarButton } from '@/components/Sidebar/SidebarButton';
|
||||
import { IconCheck, IconTrash, IconX } from "@tabler/icons-react";
|
||||
import { FC, useState } from "react";
|
||||
import { SidebarButton } from "./SidebarButton";
|
||||
|
||||
interface Props {
|
||||
onClearConversations: () => void;
|
||||
@@ -12,24 +9,20 @@ 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 w-full cursor-pointer items-center rounded-lg py-3 px-3 hover:bg-gray-500/10">
|
||||
<IconTrash size={18} />
|
||||
<div className="flex hover:bg-[#343541] py-2 px-2 rounded-md cursor-pointer w-full items-center">
|
||||
<IconTrash size={16} />
|
||||
|
||||
<div className="ml-3 flex-1 text-left text-[12.5px] leading-3 text-white">
|
||||
{t('Are you sure?')}
|
||||
</div>
|
||||
<div className="ml-2 flex-1 text-left text-white">Are you sure?</div>
|
||||
|
||||
<div className="flex w-[40px]">
|
||||
<IconCheck
|
||||
className="ml-auto mr-1 min-w-[20px] text-neutral-400 hover:text-neutral-100"
|
||||
className="ml-auto min-w-[20px] text-neutral-400 hover:text-neutral-100"
|
||||
size={18}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -49,8 +42,8 @@ export const ClearConversations: FC<Props> = ({ onClearConversations }) => {
|
||||
</div>
|
||||
) : (
|
||||
<SidebarButton
|
||||
text={t('Clear conversations')}
|
||||
icon={<IconTrash size={18} />}
|
||||
text="Clear conversations"
|
||||
icon={<IconTrash size={16} />}
|
||||
onClick={() => setIsConfirming(true)}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,124 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
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,9 +1,6 @@
|
||||
import { IconCheck, IconKey, IconX } from '@tabler/icons-react';
|
||||
import { FC, KeyboardEvent, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { SidebarButton } from '../Sidebar/SidebarButton';
|
||||
import { IconCheck, IconKey, IconX } from "@tabler/icons-react";
|
||||
import { FC, KeyboardEvent, useState } from "react";
|
||||
import { SidebarButton } from "./SidebarButton";
|
||||
|
||||
interface Props {
|
||||
apiKey: string;
|
||||
@@ -11,13 +8,11 @@ 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);
|
||||
}
|
||||
@@ -28,24 +23,16 @@ export const Key: FC<Props> = ({ apiKey, onApiKeyChange }) => {
|
||||
setIsChanging(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isChanging) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [isChanging]);
|
||||
|
||||
return isChanging ? (
|
||||
<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} />
|
||||
<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} />
|
||||
|
||||
<input
|
||||
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"
|
||||
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"
|
||||
type="password"
|
||||
value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)}
|
||||
onKeyDown={handleEnterDown}
|
||||
placeholder={t('API Key') || 'API Key'}
|
||||
/>
|
||||
|
||||
<div className="flex w-[40px]">
|
||||
@@ -71,8 +58,8 @@ export const Key: FC<Props> = ({ apiKey, onApiKeyChange }) => {
|
||||
</div>
|
||||
) : (
|
||||
<SidebarButton
|
||||
text={t('OpenAI API Key')}
|
||||
icon={<IconKey size={18} />}
|
||||
text="OpenAI API Key"
|
||||
icon={<IconKey size={16} />}
|
||||
onClick={() => setIsChanging(true)}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,37 @@
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
import { FC } from "react";
|
||||
|
||||
interface Props {
|
||||
searchTerm: string;
|
||||
onSearch: (searchTerm: string) => void;
|
||||
}
|
||||
|
||||
export const Search: FC<Props> = ({ searchTerm, onSearch }) => {
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onSearch(e.target.value);
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
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"
|
||||
type="text"
|
||||
placeholder="Search conversations..."
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
|
||||
{searchTerm && (
|
||||
<IconX
|
||||
className="absolute right-4 text-neutral-300 cursor-pointer hover:text-neutral-400"
|
||||
size={24}
|
||||
onClick={clearSearch}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+85
-111
@@ -1,123 +1,97 @@
|
||||
import { IconFolderPlus, IconMistOff, IconPlus } from '@tabler/icons-react';
|
||||
import { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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";
|
||||
|
||||
import {
|
||||
CloseSidebarButton,
|
||||
OpenSidebarButton,
|
||||
} from './components/OpenCloseButton';
|
||||
|
||||
import Search from '../Search';
|
||||
|
||||
interface Props<T> {
|
||||
isOpen: boolean;
|
||||
addItemButtonTitle: string;
|
||||
side: 'left' | 'right';
|
||||
items: T[];
|
||||
itemComponent: ReactNode;
|
||||
folderComponent: ReactNode;
|
||||
footerComponent?: ReactNode;
|
||||
searchTerm: string;
|
||||
handleSearchTerm: (searchTerm: string) => void;
|
||||
toggleOpen: () => void;
|
||||
handleCreateItem: () => void;
|
||||
handleCreateFolder: () => void;
|
||||
handleDrop: (e: any) => void;
|
||||
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;
|
||||
}
|
||||
|
||||
const Sidebar = <T,>({
|
||||
isOpen,
|
||||
addItemButtonTitle,
|
||||
side,
|
||||
items,
|
||||
itemComponent,
|
||||
folderComponent,
|
||||
footerComponent,
|
||||
searchTerm,
|
||||
handleSearchTerm,
|
||||
toggleOpen,
|
||||
handleCreateItem,
|
||||
handleCreateFolder,
|
||||
handleDrop,
|
||||
}: Props<T>) => {
|
||||
const { t } = useTranslation('promptbar');
|
||||
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);
|
||||
|
||||
const allowDrop = (e: any) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
useEffect(() => {
|
||||
if (searchTerm) {
|
||||
setFilteredConversations(conversations.filter((conversation) => conversation.name.toLowerCase().includes(searchTerm.toLowerCase())));
|
||||
} else {
|
||||
setFilteredConversations(conversations);
|
||||
}
|
||||
}, [searchTerm, conversations]);
|
||||
|
||||
const highlightDrop = (e: any) => {
|
||||
e.target.style.background = '#343541';
|
||||
};
|
||||
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>
|
||||
|
||||
const removeHighlight = (e: any) => {
|
||||
e.target.style.background = 'none';
|
||||
};
|
||||
|
||||
return isOpen ? (
|
||||
<div>
|
||||
<div
|
||||
className={`fixed top-0 ${side}-0 z-40 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={() => {
|
||||
handleCreateItem();
|
||||
handleSearchTerm('');
|
||||
}}
|
||||
>
|
||||
<IconPlus size={16} />
|
||||
{addItemButtonTitle}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="ml-2 flex flex-shrink-0 cursor-pointer items-center gap-3 rounded-md border border-white/20 p-3 text-sm text-white transition-colors duration-200 hover:bg-gray-500/10"
|
||||
onClick={handleCreateFolder}
|
||||
>
|
||||
<IconFolderPlus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<Search
|
||||
placeholder={t('Search...') || ''}
|
||||
searchTerm={searchTerm}
|
||||
onSearch={handleSearchTerm}
|
||||
<IconArrowBarLeft
|
||||
className="ml-1 p-1 text-neutral-300 cursor-pointer hover:text-neutral-400 hidden sm:flex"
|
||||
size={32}
|
||||
onClick={onToggleSidebar}
|
||||
/>
|
||||
|
||||
<div className="flex-grow overflow-auto">
|
||||
{items?.length > 0 && (
|
||||
<div className="flex border-b border-white/20 pb-2">
|
||||
{folderComponent}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items?.length > 0 ? (
|
||||
<div
|
||||
className="pt-2"
|
||||
onDrop={handleDrop}
|
||||
onDragOver={allowDrop}
|
||||
onDragEnter={highlightDrop}
|
||||
onDragLeave={removeHighlight}
|
||||
>
|
||||
{itemComponent}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-8 select-none text-center text-white opacity-50">
|
||||
<IconMistOff className="mx-auto mb-3" />
|
||||
<span className="text-[14px] leading-normal">
|
||||
{t('No prompts.')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{footerComponent}
|
||||
</div>
|
||||
|
||||
<CloseSidebarButton onClick={toggleOpen} side={side} />
|
||||
{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>
|
||||
) : (
|
||||
<OpenSidebarButton onClick={toggleOpen} side={side} />
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
|
||||
@@ -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 (
|
||||
<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"
|
||||
<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"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div>{icon}</div>
|
||||
<span>{text}</span>
|
||||
</button>
|
||||
<div>{text}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
import { IconArrowBarLeft, IconArrowBarRight } from '@tabler/icons-react';
|
||||
|
||||
interface Props {
|
||||
onClick: any;
|
||||
side: 'left' | 'right';
|
||||
}
|
||||
|
||||
export const CloseSidebarButton = ({ onClick, side }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className={`fixed top-5 ${
|
||||
side === 'right' ? 'right-[270px]' : 'left-[270px]'
|
||||
} z-50 h-7 w-7 hover:text-gray-400 dark:text-white dark:hover:text-gray-300 sm:top-0.5 sm:${
|
||||
side === 'right' ? 'right-[270px]' : 'left-[270px]'
|
||||
} sm:h-8 sm:w-8 sm:text-neutral-700`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{side === 'right' ? <IconArrowBarRight /> : <IconArrowBarLeft />}
|
||||
</button>
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="absolute top-0 left-0 z-10 h-full w-full bg-black opacity-70 sm:hidden"
|
||||
></div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const OpenSidebarButton = ({ onClick, side }: Props) => {
|
||||
return (
|
||||
<button
|
||||
className={`fixed top-2.5 ${
|
||||
side === 'right' ? 'right-2' : 'left-2'
|
||||
} z-50 h-7 w-7 text-white hover:text-gray-400 dark:text-white dark:hover:text-gray-300 sm:top-0.5 sm:${
|
||||
side === 'right' ? 'right-2' : 'left-2'
|
||||
} sm:h-8 sm:w-8 sm:text-neutral-700`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{side === 'right' ? <IconArrowBarLeft /> : <IconArrowBarRight />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './Sidebar';
|
||||
@@ -1,34 +0,0 @@
|
||||
import { FC } from 'react';
|
||||
|
||||
interface Props {
|
||||
size?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Spinner = ({ size = '1em', className = '' }: Props) => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
export default Spinner;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './Spinner';
|
||||
@@ -1,21 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,30 +0,0 @@
|
||||
import { useMemo, useReducer } from 'react';
|
||||
|
||||
// Extracts property names from initial state of reducer to allow typesafe dispatch objects
|
||||
export type FieldNames<T> = {
|
||||
[K in keyof T]: T[K] extends string ? K : K;
|
||||
}[keyof T];
|
||||
|
||||
// Returns the Action Type for the dispatch object to be used for typing in things like context
|
||||
export type ActionType<T> =
|
||||
| { type: 'reset' }
|
||||
| { type?: 'change'; field: FieldNames<T>; value: any };
|
||||
|
||||
// Returns a typed dispatch and state
|
||||
export const useCreateReducer = <T>({ initialState }: { initialState: T }) => {
|
||||
type Action =
|
||||
| { type: 'reset' }
|
||||
| { type?: 'change'; field: FieldNames<T>; value: any };
|
||||
|
||||
const reducer = (state: T, action: Action) => {
|
||||
if (!action.type) return { ...state, [action.field]: action.value };
|
||||
|
||||
if (action.type === 'reset') return initialState;
|
||||
|
||||
throw new Error();
|
||||
};
|
||||
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
return useMemo(() => ({ state, dispatch }), [state, dispatch]);
|
||||
};
|
||||
@@ -1,88 +0,0 @@
|
||||
export type RequestModel = {
|
||||
params?: object;
|
||||
headers?: object;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export type RequestWithBodyModel = RequestModel & {
|
||||
body?: object | FormData;
|
||||
};
|
||||
|
||||
export const useFetch = () => {
|
||||
const handleFetch = async (
|
||||
url: string,
|
||||
request: any,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
const requestUrl = request?.params ? `${url}${request.params}` : url;
|
||||
|
||||
const requestBody = request?.body
|
||||
? request.body instanceof FormData
|
||||
? { ...request, body: request.body }
|
||||
: { ...request, body: JSON.stringify(request.body) }
|
||||
: request;
|
||||
|
||||
const headers = {
|
||||
...(request?.headers
|
||||
? request.headers
|
||||
: request?.body && request.body instanceof FormData
|
||||
? {}
|
||||
: { 'Content-type': 'application/json' }),
|
||||
};
|
||||
|
||||
return fetch(requestUrl, { ...requestBody, headers, signal })
|
||||
.then((response) => {
|
||||
if (!response.ok) throw response;
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
const contentDisposition = response.headers.get('content-disposition');
|
||||
|
||||
const headers = response.headers;
|
||||
|
||||
const result =
|
||||
contentType &&
|
||||
(contentType?.indexOf('application/json') !== -1 ||
|
||||
contentType?.indexOf('text/plain') !== -1)
|
||||
? response.json()
|
||||
: contentDisposition?.indexOf('attachment') !== -1
|
||||
? response.blob()
|
||||
: response;
|
||||
|
||||
return result;
|
||||
})
|
||||
.catch(async (err) => {
|
||||
const contentType = err.headers.get('content-type');
|
||||
|
||||
const errResult =
|
||||
contentType && contentType?.indexOf('application/problem+json') !== -1
|
||||
? await err.json()
|
||||
: err;
|
||||
|
||||
throw errResult;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
get: async <T>(url: string, request?: RequestModel): Promise<T> => {
|
||||
return handleFetch(url, { ...request, method: 'get' });
|
||||
},
|
||||
post: async <T>(
|
||||
url: string,
|
||||
request?: RequestWithBodyModel,
|
||||
): Promise<T> => {
|
||||
return handleFetch(url, { ...request, method: 'post' });
|
||||
},
|
||||
put: async <T>(url: string, request?: RequestWithBodyModel): Promise<T> => {
|
||||
return handleFetch(url, { ...request, method: 'put' });
|
||||
},
|
||||
patch: async <T>(
|
||||
url: string,
|
||||
request?: RequestWithBodyModel,
|
||||
): Promise<T> => {
|
||||
return handleFetch(url, { ...request, method: 'patch' });
|
||||
},
|
||||
delete: async <T>(url: string, request?: RequestModel): Promise<T> => {
|
||||
return handleFetch(url, { ...request, method: 'delete' });
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,60 +0,0 @@
|
||||
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
|
||||
@@ -1,31 +0,0 @@
|
||||
module.exports = {
|
||||
i18n: {
|
||||
defaultLocale: 'en',
|
||||
locales: [
|
||||
"bn",
|
||||
"de",
|
||||
"en",
|
||||
"es",
|
||||
"fr",
|
||||
"he",
|
||||
"id",
|
||||
"it",
|
||||
"ja",
|
||||
"ko",
|
||||
"pl",
|
||||
"pt",
|
||||
"ru",
|
||||
"ro",
|
||||
"sv",
|
||||
"te",
|
||||
"vi",
|
||||
"zh",
|
||||
"ar",
|
||||
"tr",
|
||||
],
|
||||
},
|
||||
localePath:
|
||||
typeof window === 'undefined'
|
||||
? require('path').resolve('./public/locales')
|
||||
: '/public/locales',
|
||||
};
|
||||
+4
-5
@@ -1,18 +1,17 @@
|
||||
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
+386
-5975
File diff suppressed because it is too large
Load Diff
+21
-39
@@ -4,55 +4,37 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"build": "next build && next export -o dist",
|
||||
"export": "next export",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"format": "prettier --write .",
|
||||
"test": "vitest",
|
||||
"coverage": "vitest run --coverage"
|
||||
"tauri": "tauri",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dqbd/tiktoken": "^1.0.2",
|
||||
"@tabler/icons-react": "^2.9.0",
|
||||
"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-query": "^3.39.3",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"rehype-mathjax": "^4.0.2",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"remark-math": "^5.1.1",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mozilla/readability": "^0.4.4",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@trivago/prettier-plugin-sort-imports": "^4.1.1",
|
||||
"@types/jsdom": "^21.1.1",
|
||||
"@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",
|
||||
"@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",
|
||||
"eventsource-parser": "^0.1.0",
|
||||
"next": "13.2.4",
|
||||
"openai": "^3.2.1",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-markdown": "^8.0.5",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"typescript": "4.9.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/react-syntax-highlighter": "^15.5.6",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"postcss": "^8.4.21",
|
||||
"prettier": "^2.8.7",
|
||||
"prettier-plugin-tailwindcss": "^0.2.5",
|
||||
"tailwindcss": "^3.2.7",
|
||||
"typescript": "4.9.5",
|
||||
"vitest": "^0.29.7"
|
||||
"tailwindcss": "^3.2.7"
|
||||
}
|
||||
}
|
||||
|
||||
+8
-20
@@ -1,25 +1,13 @@
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import "@/styles/globals.css";
|
||||
import type { AppProps } from "next/app";
|
||||
import { Inter } from "next/font/google";
|
||||
|
||||
import { appWithTranslation } from 'next-i18next';
|
||||
import type { AppProps } from 'next/app';
|
||||
import { Inter } from 'next/font/google';
|
||||
|
||||
import '@/styles/globals.css';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
function App({ Component, pageProps }: AppProps<{}>) {
|
||||
const queryClient = new QueryClient();
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps<{}>) {
|
||||
return (
|
||||
<div className={inter.className}>
|
||||
<Toaster />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Component {...pageProps} />
|
||||
</QueryClientProvider>
|
||||
</div>
|
||||
<main className={inter.className}>
|
||||
<Component {...pageProps} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default appWithTranslation(App);
|
||||
|
||||
+5
-13
@@ -1,18 +1,10 @@
|
||||
import { DocumentProps, Head, Html, Main, NextScript } from 'next/document';
|
||||
import { Html, Head, Main, NextScript } from 'next/document'
|
||||
|
||||
import i18nextConfig from '../next-i18next.config';
|
||||
|
||||
type Props = DocumentProps & {
|
||||
// add custom document props
|
||||
};
|
||||
|
||||
export default function Document(props: Props) {
|
||||
const currentLocale =
|
||||
props.__NEXT_DATA__.locale ?? i18nextConfig.i18n.defaultLocale;
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html lang={currentLocale}>
|
||||
<Html lang="en">
|
||||
<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>
|
||||
@@ -20,5 +12,5 @@ export default function Document(props: Props) {
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
+19
-36
@@ -1,49 +1,31 @@
|
||||
import { DEFAULT_SYSTEM_PROMPT, DEFAULT_TEMPERATURE } from '@/utils/app/const';
|
||||
import { OpenAIError, OpenAIStream } from '@/utils/server';
|
||||
|
||||
import { ChatBody, Message } from '@/types/chat';
|
||||
|
||||
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";
|
||||
// @ts-expect-error
|
||||
import wasm from '../../node_modules/@dqbd/tiktoken/lite/tiktoken_bg.wasm?module';
|
||||
|
||||
import tiktokenModel from '@dqbd/tiktoken/encoders/cl100k_base.json';
|
||||
import { Tiktoken, init } from '@dqbd/tiktoken/lite/init';
|
||||
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> => {
|
||||
try {
|
||||
const { model, messages, key, prompt, temperature } = (await req.json()) as ChatBody;
|
||||
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);
|
||||
|
||||
let promptToSend = prompt;
|
||||
if (!promptToSend) {
|
||||
promptToSend = DEFAULT_SYSTEM_PROMPT;
|
||||
}
|
||||
|
||||
let temperatureToUse = temperature;
|
||||
if (temperatureToUse == null) {
|
||||
temperatureToUse = DEFAULT_TEMPERATURE;
|
||||
}
|
||||
|
||||
const prompt_tokens = encoding.encode(promptToSend);
|
||||
|
||||
let tokenCount = prompt_tokens.length;
|
||||
const tokenLimit = model.id === OpenAIModelID.GPT_4 ? 6000 : 3000;
|
||||
let tokenCount = 0;
|
||||
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 + 1000 > model.tokenLimit) {
|
||||
if (tokenCount + tokens.length > tokenLimit) {
|
||||
break;
|
||||
}
|
||||
tokenCount += tokens.length;
|
||||
@@ -52,16 +34,17 @@ const handler = async (req: Request): Promise<Response> => {
|
||||
|
||||
encoding.free();
|
||||
|
||||
const stream = await OpenAIStream(model, promptToSend, temperatureToUse, key, messagesToSend);
|
||||
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);
|
||||
if (error instanceof OpenAIError) {
|
||||
return new Response('Error', { status: 500, statusText: error.message });
|
||||
} else {
|
||||
return new Response('Error', { status: 500 });
|
||||
}
|
||||
return new Response("Error", { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import { OPENAI_API_HOST } from '@/utils/app/const';
|
||||
import { cleanSourceText } from '@/utils/server/google';
|
||||
|
||||
import { Message } from '@/types/chat';
|
||||
import { GoogleBody, GoogleSource } from '@/types/google';
|
||||
|
||||
import { Readability } from '@mozilla/readability';
|
||||
import endent from 'endent';
|
||||
import jsdom, { JSDOM } from 'jsdom';
|
||||
|
||||
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 query = encodeURIComponent(userMessage.content.trim());
|
||||
|
||||
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=${query}&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. Maximum 4 sentences.`,
|
||||
},
|
||||
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) {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: 'Error'})
|
||||
}
|
||||
};
|
||||
|
||||
export default handler;
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Dispatch, createContext } from 'react';
|
||||
|
||||
import { ActionType } from '@/hooks/useCreateReducer';
|
||||
|
||||
import { Conversation } from '@/types/chat';
|
||||
import { KeyValuePair } from '@/types/data';
|
||||
import { FolderType } from '@/types/folder';
|
||||
|
||||
import { HomeInitialState } from './home.state';
|
||||
|
||||
export interface HomeContextProps {
|
||||
state: HomeInitialState;
|
||||
dispatch: Dispatch<ActionType<HomeInitialState>>;
|
||||
handleNewConversation: () => void;
|
||||
handleCreateFolder: (name: string, type: FolderType) => void;
|
||||
handleDeleteFolder: (folderId: string) => void;
|
||||
handleUpdateFolder: (folderId: string, name: string) => void;
|
||||
handleSelectConversation: (conversation: Conversation) => void;
|
||||
handleUpdateConversation: (
|
||||
conversation: Conversation,
|
||||
data: KeyValuePair,
|
||||
) => void;
|
||||
}
|
||||
|
||||
const HomeContext = createContext<HomeContextProps>(undefined!);
|
||||
|
||||
export default HomeContext;
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Conversation, Message } from '@/types/chat';
|
||||
import { ErrorMessage } from '@/types/error';
|
||||
import { FolderInterface } from '@/types/folder';
|
||||
import { OpenAIModel, OpenAIModelID } from '@/types/openai';
|
||||
import { PluginKey } from '@/types/plugin';
|
||||
import { Prompt } from '@/types/prompt';
|
||||
|
||||
export interface HomeInitialState {
|
||||
apiKey: string;
|
||||
pluginKeys: PluginKey[];
|
||||
loading: boolean;
|
||||
lightMode: 'light' | 'dark';
|
||||
messageIsStreaming: boolean;
|
||||
modelError: ErrorMessage | null;
|
||||
models: OpenAIModel[];
|
||||
folders: FolderInterface[];
|
||||
conversations: Conversation[];
|
||||
selectedConversation: Conversation | undefined;
|
||||
currentMessage: Message | undefined;
|
||||
prompts: Prompt[];
|
||||
temperature: number;
|
||||
showChatbar: boolean;
|
||||
showPromptbar: boolean;
|
||||
currentFolder: FolderInterface | undefined;
|
||||
messageError: boolean;
|
||||
searchTerm: string;
|
||||
defaultModelId: OpenAIModelID | undefined;
|
||||
serverSideApiKeyIsSet: boolean;
|
||||
serverSidePluginKeysSet: boolean;
|
||||
}
|
||||
|
||||
export const initialState: HomeInitialState = {
|
||||
apiKey: '',
|
||||
loading: false,
|
||||
pluginKeys: [],
|
||||
lightMode: 'dark',
|
||||
messageIsStreaming: false,
|
||||
modelError: null,
|
||||
models: [],
|
||||
folders: [],
|
||||
conversations: [],
|
||||
selectedConversation: undefined,
|
||||
currentMessage: undefined,
|
||||
prompts: [],
|
||||
temperature: 1,
|
||||
showPromptbar: true,
|
||||
showChatbar: true,
|
||||
currentFolder: undefined,
|
||||
messageError: false,
|
||||
searchTerm: '',
|
||||
defaultModelId: undefined,
|
||||
serverSideApiKeyIsSet: false,
|
||||
serverSidePluginKeysSet: false,
|
||||
};
|
||||
@@ -1,431 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
|
||||
import { GetServerSideProps } from 'next';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
||||
import Head from 'next/head';
|
||||
|
||||
import { useCreateReducer } from '@/hooks/useCreateReducer';
|
||||
|
||||
import useErrorService from '@/services/errorService';
|
||||
import useApiService from '@/services/useApiService';
|
||||
|
||||
import {
|
||||
cleanConversationHistory,
|
||||
cleanSelectedConversation,
|
||||
} from '@/utils/app/clean';
|
||||
import { DEFAULT_SYSTEM_PROMPT, DEFAULT_TEMPERATURE } from '@/utils/app/const';
|
||||
import {
|
||||
saveConversation,
|
||||
saveConversations,
|
||||
updateConversation,
|
||||
} from '@/utils/app/conversation';
|
||||
import { saveFolders } from '@/utils/app/folders';
|
||||
import { savePrompts } from '@/utils/app/prompts';
|
||||
import { getSettings } from '@/utils/app/settings';
|
||||
|
||||
import { Conversation } from '@/types/chat';
|
||||
import { KeyValuePair } from '@/types/data';
|
||||
import { FolderInterface, FolderType } from '@/types/folder';
|
||||
import { OpenAIModelID, OpenAIModels, fallbackModelID } from '@/types/openai';
|
||||
import { Prompt } from '@/types/prompt';
|
||||
|
||||
import { Chat } from '@/components/Chat/Chat';
|
||||
import { Chatbar } from '@/components/Chatbar/Chatbar';
|
||||
import { Navbar } from '@/components/Mobile/Navbar';
|
||||
import Promptbar from '@/components/Promptbar';
|
||||
|
||||
import HomeContext from './home.context';
|
||||
import { HomeInitialState, initialState } from './home.state';
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
interface Props {
|
||||
serverSideApiKeyIsSet: boolean;
|
||||
serverSidePluginKeysSet: boolean;
|
||||
defaultModelId: OpenAIModelID;
|
||||
}
|
||||
|
||||
const Home = ({
|
||||
serverSideApiKeyIsSet,
|
||||
serverSidePluginKeysSet,
|
||||
defaultModelId,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const { getModels } = useApiService();
|
||||
const { getModelsError } = useErrorService();
|
||||
const [initialRender, setInitialRender] = useState<boolean>(true);
|
||||
|
||||
const contextValue = useCreateReducer<HomeInitialState>({
|
||||
initialState,
|
||||
});
|
||||
|
||||
const {
|
||||
state: {
|
||||
apiKey,
|
||||
lightMode,
|
||||
folders,
|
||||
conversations,
|
||||
selectedConversation,
|
||||
prompts,
|
||||
temperature,
|
||||
},
|
||||
dispatch,
|
||||
} = contextValue;
|
||||
|
||||
const stopConversationRef = useRef<boolean>(false);
|
||||
|
||||
const { data, error, refetch } = useQuery(
|
||||
['GetModels', apiKey, serverSideApiKeyIsSet],
|
||||
({ signal }) => {
|
||||
if (!apiKey && !serverSideApiKeyIsSet) return null;
|
||||
|
||||
return getModels(
|
||||
{
|
||||
key: apiKey,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
},
|
||||
{ enabled: true, refetchOnMount: false },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) dispatch({ field: 'models', value: data });
|
||||
}, [data, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ field: 'modelError', value: getModelsError(error) });
|
||||
}, [dispatch, error, getModelsError]);
|
||||
|
||||
// FETCH MODELS ----------------------------------------------
|
||||
|
||||
const handleSelectConversation = (conversation: Conversation) => {
|
||||
dispatch({
|
||||
field: 'selectedConversation',
|
||||
value: conversation,
|
||||
});
|
||||
|
||||
saveConversation(conversation);
|
||||
};
|
||||
|
||||
// FOLDER OPERATIONS --------------------------------------------
|
||||
|
||||
const handleCreateFolder = (name: string, type: FolderType) => {
|
||||
const newFolder: FolderInterface = {
|
||||
id: uuidv4(),
|
||||
name,
|
||||
type,
|
||||
};
|
||||
|
||||
const updatedFolders = [...folders, newFolder];
|
||||
|
||||
dispatch({ field: 'folders', value: updatedFolders });
|
||||
saveFolders(updatedFolders);
|
||||
};
|
||||
|
||||
const handleDeleteFolder = (folderId: string) => {
|
||||
const updatedFolders = folders.filter((f) => f.id !== folderId);
|
||||
dispatch({ field: 'folders', value: updatedFolders });
|
||||
saveFolders(updatedFolders);
|
||||
|
||||
const updatedConversations: Conversation[] = conversations.map((c) => {
|
||||
if (c.folderId === folderId) {
|
||||
return {
|
||||
...c,
|
||||
folderId: null,
|
||||
};
|
||||
}
|
||||
|
||||
return c;
|
||||
});
|
||||
|
||||
dispatch({ field: 'conversations', value: updatedConversations });
|
||||
saveConversations(updatedConversations);
|
||||
|
||||
const updatedPrompts: Prompt[] = prompts.map((p) => {
|
||||
if (p.folderId === folderId) {
|
||||
return {
|
||||
...p,
|
||||
folderId: null,
|
||||
};
|
||||
}
|
||||
|
||||
return p;
|
||||
});
|
||||
|
||||
dispatch({ field: 'prompts', value: updatedPrompts });
|
||||
savePrompts(updatedPrompts);
|
||||
};
|
||||
|
||||
const handleUpdateFolder = (folderId: string, name: string) => {
|
||||
const updatedFolders = folders.map((f) => {
|
||||
if (f.id === folderId) {
|
||||
return {
|
||||
...f,
|
||||
name,
|
||||
};
|
||||
}
|
||||
|
||||
return f;
|
||||
});
|
||||
|
||||
dispatch({ field: 'folders', value: updatedFolders });
|
||||
|
||||
saveFolders(updatedFolders);
|
||||
};
|
||||
|
||||
// CONVERSATION OPERATIONS --------------------------------------------
|
||||
|
||||
const handleNewConversation = () => {
|
||||
const lastConversation = conversations[conversations.length - 1];
|
||||
|
||||
const newConversation: Conversation = {
|
||||
id: uuidv4(),
|
||||
name: `${t('New Conversation')}`,
|
||||
messages: [],
|
||||
model: lastConversation?.model || {
|
||||
id: OpenAIModels[defaultModelId].id,
|
||||
name: OpenAIModels[defaultModelId].name,
|
||||
maxLength: OpenAIModels[defaultModelId].maxLength,
|
||||
tokenLimit: OpenAIModels[defaultModelId].tokenLimit,
|
||||
},
|
||||
prompt: DEFAULT_SYSTEM_PROMPT,
|
||||
temperature: lastConversation?.temperature ?? DEFAULT_TEMPERATURE,
|
||||
folderId: null,
|
||||
};
|
||||
|
||||
const updatedConversations = [...conversations, newConversation];
|
||||
|
||||
dispatch({ field: 'selectedConversation', value: newConversation });
|
||||
dispatch({ field: 'conversations', value: updatedConversations });
|
||||
|
||||
saveConversation(newConversation);
|
||||
saveConversations(updatedConversations);
|
||||
|
||||
dispatch({ field: 'loading', value: false });
|
||||
};
|
||||
|
||||
const handleUpdateConversation = (
|
||||
conversation: Conversation,
|
||||
data: KeyValuePair,
|
||||
) => {
|
||||
const updatedConversation = {
|
||||
...conversation,
|
||||
[data.key]: data.value,
|
||||
};
|
||||
|
||||
const { single, all } = updateConversation(
|
||||
updatedConversation,
|
||||
conversations,
|
||||
);
|
||||
|
||||
dispatch({ field: 'selectedConversation', value: single });
|
||||
dispatch({ field: 'conversations', value: all });
|
||||
};
|
||||
|
||||
// EFFECTS --------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
if (window.innerWidth < 640) {
|
||||
dispatch({ field: 'showChatbar', value: false });
|
||||
}
|
||||
}, [selectedConversation]);
|
||||
|
||||
useEffect(() => {
|
||||
defaultModelId &&
|
||||
dispatch({ field: 'defaultModelId', value: defaultModelId });
|
||||
serverSideApiKeyIsSet &&
|
||||
dispatch({
|
||||
field: 'serverSideApiKeyIsSet',
|
||||
value: serverSideApiKeyIsSet,
|
||||
});
|
||||
serverSidePluginKeysSet &&
|
||||
dispatch({
|
||||
field: 'serverSidePluginKeysSet',
|
||||
value: serverSidePluginKeysSet,
|
||||
});
|
||||
}, [defaultModelId, serverSideApiKeyIsSet, serverSidePluginKeysSet]);
|
||||
|
||||
// ON LOAD --------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
const settings = getSettings();
|
||||
if (settings.theme) {
|
||||
dispatch({
|
||||
field: 'lightMode',
|
||||
value: settings.theme,
|
||||
});
|
||||
}
|
||||
|
||||
const apiKey = localStorage.getItem('apiKey');
|
||||
|
||||
if (serverSideApiKeyIsSet) {
|
||||
dispatch({ field: 'apiKey', value: '' });
|
||||
|
||||
localStorage.removeItem('apiKey');
|
||||
} else if (apiKey) {
|
||||
dispatch({ field: 'apiKey', value: apiKey });
|
||||
}
|
||||
|
||||
const pluginKeys = localStorage.getItem('pluginKeys');
|
||||
if (serverSidePluginKeysSet) {
|
||||
dispatch({ field: 'pluginKeys', value: [] });
|
||||
localStorage.removeItem('pluginKeys');
|
||||
} else if (pluginKeys) {
|
||||
dispatch({ field: 'pluginKeys', value: pluginKeys });
|
||||
}
|
||||
|
||||
if (window.innerWidth < 640) {
|
||||
dispatch({ field: 'showChatbar', value: false });
|
||||
dispatch({ field: 'showPromptbar', value: false });
|
||||
}
|
||||
|
||||
const showChatbar = localStorage.getItem('showChatbar');
|
||||
if (showChatbar) {
|
||||
dispatch({ field: 'showChatbar', value: showChatbar === 'true' });
|
||||
}
|
||||
|
||||
const showPromptbar = localStorage.getItem('showPromptbar');
|
||||
if (showPromptbar) {
|
||||
dispatch({ field: 'showPromptbar', value: showPromptbar === 'true' });
|
||||
}
|
||||
|
||||
const folders = localStorage.getItem('folders');
|
||||
if (folders) {
|
||||
dispatch({ field: 'folders', value: JSON.parse(folders) });
|
||||
}
|
||||
|
||||
const prompts = localStorage.getItem('prompts');
|
||||
if (prompts) {
|
||||
dispatch({ field: 'prompts', value: JSON.parse(prompts) });
|
||||
}
|
||||
|
||||
const conversationHistory = localStorage.getItem('conversationHistory');
|
||||
if (conversationHistory) {
|
||||
const parsedConversationHistory: Conversation[] =
|
||||
JSON.parse(conversationHistory);
|
||||
const cleanedConversationHistory = cleanConversationHistory(
|
||||
parsedConversationHistory,
|
||||
);
|
||||
|
||||
dispatch({ field: 'conversations', value: cleanedConversationHistory });
|
||||
}
|
||||
|
||||
const selectedConversation = localStorage.getItem('selectedConversation');
|
||||
if (selectedConversation) {
|
||||
const parsedSelectedConversation: Conversation =
|
||||
JSON.parse(selectedConversation);
|
||||
const cleanedSelectedConversation = cleanSelectedConversation(
|
||||
parsedSelectedConversation,
|
||||
);
|
||||
|
||||
dispatch({
|
||||
field: 'selectedConversation',
|
||||
value: cleanedSelectedConversation,
|
||||
});
|
||||
} else {
|
||||
const lastConversation = conversations[conversations.length - 1];
|
||||
dispatch({
|
||||
field: 'selectedConversation',
|
||||
value: {
|
||||
id: uuidv4(),
|
||||
name: 'New conversation',
|
||||
messages: [],
|
||||
model: OpenAIModels[defaultModelId],
|
||||
prompt: DEFAULT_SYSTEM_PROMPT,
|
||||
temperature: lastConversation?.temperature ?? DEFAULT_TEMPERATURE,
|
||||
folderId: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [
|
||||
defaultModelId,
|
||||
dispatch,
|
||||
serverSideApiKeyIsSet,
|
||||
serverSidePluginKeysSet,
|
||||
]);
|
||||
|
||||
return (
|
||||
<HomeContext.Provider
|
||||
value={{
|
||||
...contextValue,
|
||||
handleNewConversation,
|
||||
handleCreateFolder,
|
||||
handleDeleteFolder,
|
||||
handleUpdateFolder,
|
||||
handleSelectConversation,
|
||||
handleUpdateConversation,
|
||||
}}
|
||||
>
|
||||
<Head>
|
||||
<title>Chatbot UI</title>
|
||||
<meta name="description" content="ChatGPT but better." />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="height=device-height ,width=device-width, initial-scale=1, user-scalable=no"
|
||||
/>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
{selectedConversation && (
|
||||
<main
|
||||
className={`flex h-screen w-screen flex-col text-sm text-white dark:text-white ${lightMode}`}
|
||||
>
|
||||
<div className="fixed top-0 w-full sm:hidden">
|
||||
<Navbar
|
||||
selectedConversation={selectedConversation}
|
||||
onNewConversation={handleNewConversation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full w-full pt-[48px] sm:pt-0">
|
||||
<Chatbar />
|
||||
|
||||
<div className="flex flex-1">
|
||||
<Chat stopConversationRef={stopConversationRef} />
|
||||
</div>
|
||||
|
||||
<Promptbar />
|
||||
</div>
|
||||
</main>
|
||||
)}
|
||||
</HomeContext.Provider>
|
||||
);
|
||||
};
|
||||
export default Home;
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async ({ locale }) => {
|
||||
const defaultModelId =
|
||||
(process.env.DEFAULT_MODEL &&
|
||||
Object.values(OpenAIModelID).includes(
|
||||
process.env.DEFAULT_MODEL as OpenAIModelID,
|
||||
) &&
|
||||
process.env.DEFAULT_MODEL) ||
|
||||
fallbackModelID;
|
||||
|
||||
let serverSidePluginKeysSet = false;
|
||||
|
||||
const googleApiKey = process.env.GOOGLE_API_KEY;
|
||||
const googleCSEId = process.env.GOOGLE_CSE_ID;
|
||||
|
||||
if (googleApiKey && googleCSEId) {
|
||||
serverSidePluginKeysSet = true;
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
serverSideApiKeyIsSet: !!process.env.OPENAI_API_KEY,
|
||||
defaultModelId,
|
||||
serverSidePluginKeysSet,
|
||||
...(await serverSideTranslations(locale ?? 'en', [
|
||||
'common',
|
||||
'chat',
|
||||
'sidebar',
|
||||
'markdown',
|
||||
'promptbar',
|
||||
'settings',
|
||||
])),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { default, getServerSideProps } from './home';
|
||||
+16
-37
@@ -1,9 +1,7 @@
|
||||
import { OPENAI_API_HOST, OPENAI_API_TYPE, OPENAI_API_VERSION, OPENAI_ORGANIZATION } from '@/utils/app/const';
|
||||
|
||||
import { OpenAIModel, OpenAIModelID, OpenAIModels } from '@/types/openai';
|
||||
import { OpenAIModel, OpenAIModelID, OpenAIModels } from "@/types";
|
||||
|
||||
export const config = {
|
||||
runtime: 'edge',
|
||||
runtime: "edge"
|
||||
};
|
||||
|
||||
const handler = async (req: Request): Promise<Response> => {
|
||||
@@ -11,61 +9,42 @@ const handler = async (req: Request): Promise<Response> => {
|
||||
const { key } = (await req.json()) as {
|
||||
key: string;
|
||||
};
|
||||
console.log("key", key);
|
||||
|
||||
let url = `${OPENAI_API_HOST}/v1/models`;
|
||||
if (OPENAI_API_TYPE === 'azure') {
|
||||
url = `${OPENAI_API_HOST}/openai/deployments?api-version=${OPENAI_API_VERSION}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
const response = await fetch("https://api.openai.com/v1/models", {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(OPENAI_API_TYPE === 'openai' && {
|
||||
Authorization: `Bearer ${key ? key : process.env.OPENAI_API_KEY}`
|
||||
}),
|
||||
...(OPENAI_API_TYPE === 'azure' && {
|
||||
'api-key': `${key ? key : process.env.OPENAI_API_KEY}`
|
||||
}),
|
||||
...((OPENAI_API_TYPE === 'openai' && OPENAI_ORGANIZATION) && {
|
||||
'OpenAI-Organization': OPENAI_ORGANIZATION,
|
||||
}),
|
||||
},
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${key ? key : process.env.OPENAI_API_KEY}`
|
||||
}
|
||||
});
|
||||
|
||||
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');
|
||||
if (response.status !== 200) {
|
||||
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) => {
|
||||
const model_name = (OPENAI_API_TYPE === 'azure') ? model.model : model.id;
|
||||
for (const [key, value] of Object.entries(OpenAIModelID)) {
|
||||
if (value === model_name) {
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+403
-1
@@ -1 +1,403 @@
|
||||
export { default, getServerSideProps } from './api/home';
|
||||
import { Chat } from "@/components/Chat/Chat";
|
||||
import { Navbar } from "@/components/Mobile/Navbar";
|
||||
import { Sidebar } from "@/components/Sidebar/Sidebar";
|
||||
import { ChatBody, Conversation, KeyValuePair, Message, OpenAIModel, OpenAIModelID, OpenAIModels } from "@/types";
|
||||
import { cleanConversationHistory, cleanSelectedConversation } from "@/utils/app/clean";
|
||||
import { DEFAULT_SYSTEM_PROMPT } from "@/utils/app/const";
|
||||
import { saveConversation, saveConversations, updateConversation } from "@/utils/app/conversation";
|
||||
import { exportConversations, importConversations } from "@/utils/app/data";
|
||||
import { IconArrowBarLeft, IconArrowBarRight } from "@tabler/icons-react";
|
||||
import Head from "next/head";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export default function Home() {
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [selectedConversation, setSelectedConversation] = useState<Conversation>();
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [models, setModels] = useState<OpenAIModel[]>([]);
|
||||
const [lightMode, setLightMode] = useState<"dark" | "light">("dark");
|
||||
const [messageIsStreaming, setMessageIsStreaming] = useState<boolean>(false);
|
||||
const [showSidebar, setShowSidebar] = useState<boolean>(true);
|
||||
const [apiKey, setApiKey] = useState<string>("");
|
||||
const [messageError, setMessageError] = useState<boolean>(false);
|
||||
const [modelError, setModelError] = useState<boolean>(false);
|
||||
const stopConversationRef = useRef<boolean>(false);
|
||||
|
||||
const handleSend = async (message: Message, isResend: boolean) => {
|
||||
if (selectedConversation) {
|
||||
let updatedConversation: Conversation;
|
||||
|
||||
if (isResend) {
|
||||
const updatedMessages = [...selectedConversation.messages];
|
||||
updatedMessages.pop();
|
||||
|
||||
updatedConversation = {
|
||||
...selectedConversation,
|
||||
messages: [...updatedMessages, message]
|
||||
};
|
||||
} else {
|
||||
updatedConversation = {
|
||||
...selectedConversation,
|
||||
messages: [...selectedConversation.messages, message]
|
||||
};
|
||||
}
|
||||
|
||||
setSelectedConversation(updatedConversation);
|
||||
setLoading(true);
|
||||
setMessageIsStreaming(true);
|
||||
setMessageError(false);
|
||||
|
||||
const chatBody: ChatBody = {
|
||||
model: updatedConversation.model,
|
||||
messages: updatedConversation.messages,
|
||||
key: apiKey,
|
||||
prompt: updatedConversation.prompt
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const response = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify(chatBody)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
setLoading(false);
|
||||
setMessageIsStreaming(false);
|
||||
setMessageError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = response.body;
|
||||
|
||||
if (!data) {
|
||||
setLoading(false);
|
||||
setMessageIsStreaming(false);
|
||||
setMessageError(true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
|
||||
const reader = data.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let done = false;
|
||||
let isFirst = true;
|
||||
let text = "";
|
||||
|
||||
while (!done) {
|
||||
if (stopConversationRef.current === true) {
|
||||
controller.abort();
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
const { value, done: doneReading } = await reader.read();
|
||||
done = doneReading;
|
||||
const chunkValue = decoder.decode(value);
|
||||
|
||||
text += chunkValue;
|
||||
|
||||
if (isFirst) {
|
||||
isFirst = false;
|
||||
const updatedMessages: Message[] = [...updatedConversation.messages, { role: "assistant", content: chunkValue }];
|
||||
|
||||
updatedConversation = {
|
||||
...updatedConversation,
|
||||
messages: updatedMessages
|
||||
};
|
||||
|
||||
setSelectedConversation(updatedConversation);
|
||||
} else {
|
||||
const updatedMessages: Message[] = updatedConversation.messages.map((message, index) => {
|
||||
if (index === updatedConversation.messages.length - 1) {
|
||||
return {
|
||||
...message,
|
||||
content: text
|
||||
};
|
||||
}
|
||||
|
||||
return message;
|
||||
});
|
||||
|
||||
updatedConversation = {
|
||||
...updatedConversation,
|
||||
messages: updatedMessages
|
||||
};
|
||||
|
||||
setSelectedConversation(updatedConversation);
|
||||
}
|
||||
}
|
||||
|
||||
saveConversation(updatedConversation);
|
||||
|
||||
const updatedConversations: Conversation[] = conversations.map((conversation) => {
|
||||
if (conversation.id === selectedConversation.id) {
|
||||
return updatedConversation;
|
||||
}
|
||||
|
||||
return conversation;
|
||||
});
|
||||
|
||||
if (updatedConversations.length === 0) {
|
||||
updatedConversations.push(updatedConversation);
|
||||
}
|
||||
|
||||
setConversations(updatedConversations);
|
||||
|
||||
saveConversations(updatedConversations);
|
||||
|
||||
setMessageIsStreaming(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchModels = async (key: string) => {
|
||||
console.log(key);
|
||||
const response = await fetch("/api/models", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
key
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
setModelError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(response.json());
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data) {
|
||||
setModelError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setModels(data);
|
||||
setModelError(false);
|
||||
};
|
||||
|
||||
const handleLightMode = (mode: "dark" | "light") => {
|
||||
setLightMode(mode);
|
||||
localStorage.setItem("theme", mode);
|
||||
};
|
||||
|
||||
const handleApiKeyChange = (apiKey: string) => {
|
||||
setApiKey(apiKey);
|
||||
localStorage.setItem("apiKey", apiKey);
|
||||
fetchModels(apiKey);
|
||||
};
|
||||
|
||||
const handleExportConversations = () => {
|
||||
exportConversations();
|
||||
};
|
||||
|
||||
const handleImportConversations = (conversations: Conversation[]) => {
|
||||
importConversations(conversations);
|
||||
setConversations(conversations);
|
||||
setSelectedConversation(conversations[conversations.length - 1]);
|
||||
};
|
||||
|
||||
const handleSelectConversation = (conversation: Conversation) => {
|
||||
setSelectedConversation(conversation);
|
||||
saveConversation(conversation);
|
||||
};
|
||||
|
||||
const handleNewConversation = () => {
|
||||
const lastConversation = conversations[conversations.length - 1];
|
||||
|
||||
const newConversation: Conversation = {
|
||||
id: lastConversation ? lastConversation.id + 1 : 1,
|
||||
name: `Conversation ${lastConversation ? lastConversation.id + 1 : 1}`,
|
||||
messages: [],
|
||||
model: OpenAIModels[OpenAIModelID.GPT_3_5],
|
||||
prompt: DEFAULT_SYSTEM_PROMPT
|
||||
};
|
||||
|
||||
const updatedConversations = [...conversations, newConversation];
|
||||
|
||||
setSelectedConversation(newConversation);
|
||||
setConversations(updatedConversations);
|
||||
|
||||
saveConversation(newConversation);
|
||||
saveConversations(updatedConversations);
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleDeleteConversation = (conversation: Conversation) => {
|
||||
const updatedConversations = conversations.filter((c) => c.id !== conversation.id);
|
||||
setConversations(updatedConversations);
|
||||
saveConversations(updatedConversations);
|
||||
|
||||
if (updatedConversations.length > 0) {
|
||||
setSelectedConversation(updatedConversations[updatedConversations.length - 1]);
|
||||
saveConversation(updatedConversations[updatedConversations.length - 1]);
|
||||
} else {
|
||||
setSelectedConversation({
|
||||
id: 1,
|
||||
name: "New conversation",
|
||||
messages: [],
|
||||
model: OpenAIModels[OpenAIModelID.GPT_3_5],
|
||||
prompt: DEFAULT_SYSTEM_PROMPT
|
||||
});
|
||||
localStorage.removeItem("selectedConversation");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateConversation = (conversation: Conversation, data: KeyValuePair) => {
|
||||
const updatedConversation = {
|
||||
...conversation,
|
||||
[data.key]: data.value
|
||||
};
|
||||
|
||||
const { single, all } = updateConversation(updatedConversation, conversations);
|
||||
|
||||
setSelectedConversation(single);
|
||||
setConversations(all);
|
||||
};
|
||||
|
||||
const handleClearConversations = () => {
|
||||
setConversations([]);
|
||||
localStorage.removeItem("conversationHistory");
|
||||
|
||||
setSelectedConversation({
|
||||
id: 1,
|
||||
name: "New conversation",
|
||||
messages: [],
|
||||
model: OpenAIModels[OpenAIModelID.GPT_3_5],
|
||||
prompt: DEFAULT_SYSTEM_PROMPT
|
||||
});
|
||||
localStorage.removeItem("selectedConversation");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (window.innerWidth < 640) {
|
||||
setShowSidebar(false);
|
||||
}
|
||||
}, [selectedConversation]);
|
||||
|
||||
useEffect(() => {
|
||||
const theme = localStorage.getItem("theme");
|
||||
if (theme) {
|
||||
setLightMode(theme as "dark" | "light");
|
||||
}
|
||||
|
||||
const apiKey = localStorage.getItem("apiKey") || "";
|
||||
if (apiKey) {
|
||||
setApiKey(apiKey);
|
||||
}
|
||||
|
||||
if (window.innerWidth < 640) {
|
||||
setShowSidebar(false);
|
||||
}
|
||||
|
||||
const conversationHistory = localStorage.getItem("conversationHistory");
|
||||
if (conversationHistory) {
|
||||
const parsedConversationHistory: Conversation[] = JSON.parse(conversationHistory);
|
||||
const cleanedConversationHistory = cleanConversationHistory(parsedConversationHistory);
|
||||
setConversations(cleanedConversationHistory);
|
||||
}
|
||||
|
||||
const selectedConversation = localStorage.getItem("selectedConversation");
|
||||
if (selectedConversation) {
|
||||
const parsedSelectedConversation: Conversation = JSON.parse(selectedConversation);
|
||||
const cleanedSelectedConversation = cleanSelectedConversation(parsedSelectedConversation);
|
||||
setSelectedConversation(cleanedSelectedConversation);
|
||||
} else {
|
||||
setSelectedConversation({
|
||||
id: 1,
|
||||
name: "New conversation",
|
||||
messages: [],
|
||||
model: OpenAIModels[OpenAIModelID.GPT_3_5],
|
||||
prompt: DEFAULT_SYSTEM_PROMPT
|
||||
});
|
||||
}
|
||||
|
||||
fetchModels(apiKey);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Chatbot UI</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="ChatGPT but better."
|
||||
/>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
href="/favicon.ico"
|
||||
/>
|
||||
</Head>
|
||||
{selectedConversation && (
|
||||
<div className={`flex flex-col h-screen w-screen text-white dark:text-white text-sm ${lightMode}`}>
|
||||
<div className="sm:hidden w-full fixed top-0">
|
||||
<Navbar
|
||||
selectedConversation={selectedConversation}
|
||||
onNewConversation={handleNewConversation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full w-full pt-[48px] sm:pt-0">
|
||||
{showSidebar ? (
|
||||
<>
|
||||
<Sidebar
|
||||
loading={messageIsStreaming}
|
||||
conversations={conversations}
|
||||
lightMode={lightMode}
|
||||
selectedConversation={selectedConversation}
|
||||
apiKey={apiKey}
|
||||
onToggleLightMode={handleLightMode}
|
||||
onNewConversation={handleNewConversation}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
onDeleteConversation={handleDeleteConversation}
|
||||
onToggleSidebar={() => setShowSidebar(!showSidebar)}
|
||||
onUpdateConversation={handleUpdateConversation}
|
||||
onApiKeyChange={handleApiKeyChange}
|
||||
onClearConversations={handleClearConversations}
|
||||
onExportConversations={handleExportConversations}
|
||||
onImportConversations={handleImportConversations}
|
||||
/>
|
||||
|
||||
<IconArrowBarLeft
|
||||
className="fixed top-2.5 left-4 sm:top-1 sm:left-4 sm:text-neutral-700 dark:text-white cursor-pointer hover:text-gray-400 dark:hover:text-gray-300 h-7 w-7 sm:h-8 sm:w-8 sm:hidden"
|
||||
onClick={() => setShowSidebar(!showSidebar)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<IconArrowBarRight
|
||||
className="fixed text-white z-50 top-2.5 left-4 sm:top-1.5 sm:left-4 sm:text-neutral-700 dark:text-white cursor-pointer hover:text-gray-400 dark:hover:text-gray-300 h-7 w-7 sm:h-8 sm:w-8"
|
||||
onClick={() => setShowSidebar(!showSidebar)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Chat
|
||||
conversation={selectedConversation}
|
||||
messageIsStreaming={messageIsStreaming}
|
||||
modelError={modelError}
|
||||
messageError={messageError}
|
||||
models={models}
|
||||
loading={loading}
|
||||
lightMode={lightMode}
|
||||
onSend={handleSend}
|
||||
onUpdateConversation={handleUpdateConversation}
|
||||
stopConversationRef={stopConversationRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -3,4 +3,4 @@ module.exports = {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
module.exports = {
|
||||
trailingComma: 'all',
|
||||
singleQuote: true,
|
||||
plugins: [
|
||||
'prettier-plugin-tailwindcss',
|
||||
'@trivago/prettier-plugin-sort-imports',
|
||||
],
|
||||
importOrder: [
|
||||
'react', // React
|
||||
'^react-.*$', // React-related imports
|
||||
'^next', // Next-related imports
|
||||
'^next-.*$', // Next-related imports
|
||||
'^next/.*$', // Next-related imports
|
||||
'^.*/hooks/.*$', // Hooks
|
||||
'^.*/services/.*$', // Services
|
||||
'^.*/utils/.*$', // Utils
|
||||
'^.*/types/.*$', // Types
|
||||
'^.*/pages/.*$', // Components
|
||||
'^.*/components/.*$', // Components
|
||||
'^[./]', // Other imports
|
||||
'.*', // Any uncaught imports
|
||||
],
|
||||
importOrderSeparation: true,
|
||||
importOrderSortSpecifiers: true,
|
||||
};
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"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?": "هل أنت متأكد أنك تريد مسح كافة الرسائل؟",
|
||||
"Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic.": "القيم الأعلى مثل 0.8 ستجعل الإخراج أكثر عشوائية، في حين أن القيم الأقل مثل 0.2 ستجعله أكثر تركيزًا وتحديدًا."
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"Copy code": "نسخ الكود",
|
||||
"Copied!": "تم النسخ!",
|
||||
"Enter file name": "أدخل اسم الملف"
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"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": "حفظ"
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"Dark mode": "الوضع الداكن",
|
||||
"Light mode": "الوضع الفاتح"
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"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": "الوضع الفاتح"
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"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": "বাতিল করুন",
|
||||
"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": "একটি .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 হল OpenAI-এর চ্যাট মডেলগুলির জন্য একটি উন্নত চ্যাটবট কিট যার লক্ষ্য হল ChatGPT-এর ইন্টারফেস এবং কার্যকারিতা অনুকরণ করা।",
|
||||
"Are you sure you want to clear all messages?": "সমস্ত বার্তা মুছে ফেলতে আপনি কি নিশ্চিত?",
|
||||
"Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic.": "০.৮ এর বেশি মান দিলে আউটপুট বেশি ইউনিক হবে, যেহেতু ০.২ এর মতো নিম্নমানের মান দিলে তা আরও ফোকাস এবং ধারাবাহিকতা বজায় থাকবে এবং নিশ্চয়তামূলক হবে।"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"Copy code": "কোড কপি করুন",
|
||||
"Copied!": "কপি করা হয়েছে!",
|
||||
"Enter file name": "ফাইল নাম লিখুন"
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"New prompt": "নতুন prompt",
|
||||
"New folder": "নতুন ফোল্ডার",
|
||||
"No prompts.": "কোনো prompts নেই।",
|
||||
"Search prompts...": "prompts অনুসন্ধান হচ্ছে...",
|
||||
"Name": "নাম",
|
||||
"Description": "বর্ণনা",
|
||||
"A description for your prompt.": "আপনার 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": "সংরক্ষণ করুন"
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"Dark mode": "ডার্ক মোড",
|
||||
"Light mode": "লাইট মোড"
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"New folder": "নতুন ফোল্ডার",
|
||||
"New chat": "নতুন আড্ডা",
|
||||
"No conversations.": "কোনো আলাপচারিতা নেই।",
|
||||
"Search conversations...": "আলাপচারিতা খুঁজুন...",
|
||||
"OpenAI API Key": "OpenAI API Key",
|
||||
"Import data": "আলাপচারিতা ইমপোর্ট",
|
||||
"Are you sure?": "আপনি কি নিশ্চিত?",
|
||||
"Clear conversations": "কথোপকথন পরিষ্কার করুন",
|
||||
"Export data": "আলাপচারিতা এক্সপোর্ট"
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"OpenAI API Key Required": "Cal la clau d'API d'OpenAI",
|
||||
"Please set your OpenAI API key in the bottom left of the sidebar.": "Si us plau, introdueix la teva clau d'API d'OpenAI a la cantonada inferior esquerra de la barra lateral.",
|
||||
"Stop Generating": "Parar de generar",
|
||||
"Prompt limit is {{maxLength}} characters": "El límit del missatge és de {{maxLength}} caràcters",
|
||||
"System Prompt": "Missatge del sistema",
|
||||
"You are ChatGPT, a large language model trained by OpenAI. Follow the user's instructions carefully. Respond using markdown.": "Ets ChatGPT, un model de llenguatge gran entrenat per OpenAI. Segueix les instruccions de l'usuari amb cura. Respon utilitzant markdown.",
|
||||
"Enter a prompt": "Introdueix un missatge",
|
||||
"Regenerate response": "Regenerar resposta",
|
||||
"Sorry, there was an error.": "Ho sentim, ha ocorregut un error.",
|
||||
"Model": "Model",
|
||||
"Conversation": "Conversa",
|
||||
"OR": "O",
|
||||
"Loading...": "Carregant...",
|
||||
"Type a message...": "Escriu un missatge...",
|
||||
"Error fetching models.": "Error en obtenir els models.",
|
||||
"AI": "IA",
|
||||
"You": "Tu",
|
||||
"Cancel": "Cancel·lar",
|
||||
"Save & Submit": "Guardar i enviar",
|
||||
"Make sure your OpenAI API key is set in the bottom left of the sidebar.": "Assegura't que has introduït la clau d'API d'OpenAI a la cantonada inferior esquerra de la barra lateral.",
|
||||
"If you completed this step, OpenAI may be experiencing issues.": "Si has completat aquest pas, OpenAI podria estar experimentant problemes.",
|
||||
"click if using a .env.local file": "fes clic si estàs utilitzant un fitxer .env.local",
|
||||
"Message limit is {{maxLength}} characters. You have entered {{valueLength}} characters.": "El límit del missatge és de {{maxLength}} caràcters. Has introduït {{valueLength}} caràcters.",
|
||||
"Please enter a message": "Si us plau, introdueix un missatge",
|
||||
"Chatbot UI is an advanced chatbot kit for OpenAI's chat models aiming to mimic ChatGPT's interface and functionality.": "Chatbot UI és un kit avançat de chatbot per als models de xat d'OpenAI que busca imitar la interfície i funcionalitat de ChatGPT.",
|
||||
"Are you sure you want to clear all messages?": "Estàs segur que vols esborrar tots els missatges?",
|
||||
"Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic.": "Valors més alts com 0,8 faran que la sortida sigui més aleatòria, mentre que valors més baixos com 0,2 la faran més enfocada i determinista."
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"Copy code": "Copiar codi",
|
||||
"Copied!": "Copiat!",
|
||||
"Enter file name": "Introdueix el nom de l'arxiu"
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"New prompt": "Nou prompt",
|
||||
"New folder": "Nova carpeta",
|
||||
"No prompts.": "No hi ha prompts.",
|
||||
"Search prompts...": "Cerca prompts...",
|
||||
"Name": "Nom",
|
||||
"Description": "Descripció",
|
||||
"A description for your prompt.": "Descripció del teu prompt.",
|
||||
"Prompt": "Prompt",
|
||||
"Prompt content. Use {{}} to denote a variable. Ex: {{name}} is a {{adjective}} {{noun}}": "Contingut del missatge. Utilitza {{}} per indicar una variable. Ex: {{nom}} és un {{adjectiu}} {{substantiu}}",
|
||||
"Save": "Guardar"
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"New folder": "Nova carpeta",
|
||||
"New chat": "Nova conversa",
|
||||
"No conversations.": "No hi ha converses.",
|
||||
"Search conversations...": "Cerca converses...",
|
||||
"OpenAI API Key": "Clau d'API d'OpenAI",
|
||||
"Import data": "Importar converses",
|
||||
"Are you sure?": "Estàs segur?",
|
||||
"Clear conversations": "Esborrar converses",
|
||||
"Export data": "Exportar converses",
|
||||
"Dark mode": "Mode fosc",
|
||||
"Light mode": "Mode clar"
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"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?",
|
||||
"Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic.": "Höhere Werte wie 0,8 machen die Ausgabe zufälliger, während niedrigere Werte wie 0,2 sie fokussierter und deterministischer machen werden."
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user