kirin-ri / memo

0 stars 0 forks source link

313 #13

Open kirin-ri opened 8 months ago

kirin-ri commented 8 months ago
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';

import { Prompts } from '../Promptbar/components/Prompts';
import { PromptbarInitialState, initialState2 } from '../Promptbar/Promptbar.state';
import { Prompt } from '@/types/prompt';
import PromptbarContext from '../Promptbar/PromptBar.context';
import { PromptFolders } from '../Promptbar/components/PromptFolders';
import { savePrompts } from '@/utils/app/prompts';

export const Chatbar = () => {
  const { t } = useTranslation('sidebar');

  const promptBarContextValue = useCreateReducer<PromptbarInitialState>({
    initialState2,
  });

  const {
    state: { prompts },
  } = useContext(HomeContext);

  const {
    state: { searchTerm2, filteredPrompts },
    dispatch: promptDispatch,
  } = promptBarContextValue;

  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 handleDrop2 = (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';
    }
  };
  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 });

    window.location.reload();
  };

  const handleClearConversations = () => {
    defaultModelId &&
      homeDispatch({
        field: 'selectedConversation',
        value: {
          id: uuidv4(),
          name: t('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: t('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]);

  useEffect(() => {
    if (searchTerm2) {
      promptDispatch({
        field: 'filteredPrompts',
        value: prompts.filter((prompt) => {
          const searchable =
            prompt.name.toLowerCase() +
            ' ' +
            prompt.description.toLowerCase() +
            ' ' +
            prompt.content.toLowerCase();
          return searchable.includes(searchTerm2.toLowerCase());
        }),
      });
    } else {
      promptDispatch({ field: 'filteredPrompts', value: prompts });
    }
  }, [searchTerm2, prompts]);

  return (
    <ChatbarContext.Provider
      value={{
        ...chatBarContextValue,
        handleDeleteConversation,
        handleClearConversations,
        handleImportConversations,
        handleExportData,
        handlePluginKeyChange,
        handleClearPluginKey,
        handleApiKeyChange,
      }}
    >
      <Sidebar<Conversation>
        side={'left'}
        isOpen={showChatbar}
        addItemButtonTitle={t('New chat')}
        addItemButtonTitle2={t('New prompt')}
        itemComponent={<Conversations conversations={filteredConversations} />}
        itemComponent2={<Conversations conversations={filteredConversations} />}
        folderComponent={<ChatFolders searchTerm={searchTerm} />}
        folderComponent2={<ChatFolders searchTerm={searchTerm} />}
        items={filteredConversations}
        items2={filteredConversations}
        searchTerm={searchTerm}
        searchTerm2={searchTerm}
        handleSearchTerm={(searchTerm: string) =>
          chatDispatch({ field: 'searchTerm', value: searchTerm })
        }
        handleSearchTerm2={(searchTerm: string) =>
          chatDispatch({ field: 'searchTerm', value: searchTerm })
        }
        toggleOpen={handleToggleChatbar}
        handleCreateItem={handleNewConversation}
        handleCreateItem2={handleNewConversation}
        handleCreateFolder={() => handleCreateFolder(t('New folder'), 'chat')}
        handleCreateFolder2={() => handleCreateFolder(t('New folder'), 'prompt')}
        handleDrop={handleDrop}
        handleDrop2={handleDrop2}
        footerComponent={<ChatbarSettings />}
      />
    </ChatbarContext.Provider>
  );
};
kirin-ri commented 8 months ago
import { Prompt } from '@/types/prompt';

export interface PromptbarInitialState {
  searchTerm2: string;
  filteredPrompts: Prompt[];
}

export const initialState2: PromptbarInitialState = {
  searchTerm2: '',
  filteredPrompts: [],
};
kirin-ri commented 8 months ago
  const promptBarContextValue = useCreateReducer<PromptbarInitialState>({
    initialState2,
  });
kirin-ri commented 8 months ago
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]);
`};```
kirin-ri commented 8 months ago
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, initialState2 } from './Promptbar.state';

import { v4 as uuidv4 } from 'uuid';

const Promptbar = () => {
  const { t } = useTranslation('promptbar');

  const promptBarContextValue = useCreateReducer<PromptbarInitialState>({
    initialState2,
  });

  const {
    state: { prompts, defaultModelId, showPromptbar },
    dispatch: homeDispatch,
    handleCreateFolder,
  } = useContext(HomeContext);

  const {
    state: { searchTerm2, 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 (searchTerm2) {
      promptDispatch({
        field: 'filteredPrompts',
        value: prompts.filter((prompt) => {
          const searchable =
            prompt.name.toLowerCase() +
            ' ' +
            prompt.description.toLowerCase() +
            ' ' +
            prompt.content.toLowerCase();
          return searchable.includes(searchTerm2.toLowerCase());
        }),
      });
    } else {
      promptDispatch({ field: 'filteredPrompts', value: prompts });
    }
  }, [searchTerm2, 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={searchTerm2}
        handleSearchTerm={(searchTerm: string) =>
          promptDispatch({ field: 'searchTerm2', value: searchTerm })
        }
        toggleOpen={handleTogglePromptbar}
        handleCreateItem={handleCreatePrompt}
        handleCreateFolder={() => handleCreateFolder(t('New folder'), 'prompt')}
        handleDrop={handleDrop}
      />
    </PromptbarContext.Provider>
  );
};

export default Promptbar;
``
kirin-ri commented 8 months ago
import { IconFolderPlus, IconMistOff, IconPlus } from '@tabler/icons-react';
import { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';

import {
  CloseSidebarButton,
  OpenSidebarButton,
} from './components/OpenCloseButton';

import Search from '../Search';

interface Props<T> {
  isOpen: boolean;
  addItemButtonTitle: string;
  addItemButtonTitle2: string;
  side: 'left' | 'right';
  items: T[];
  items2: T[];
  itemComponent: ReactNode;
  itemComponent2: ReactNode;
  folderComponent: ReactNode;
  folderComponent2: ReactNode;
  footerComponent?: ReactNode;
  searchTerm: string;
  searchTerm2: string;
  handleSearchTerm: (searchTerm: string) => void;
  handleSearchTerm2: (searchTerm: string) => void;
  toggleOpen: () => void;
  handleCreateItem: () => void;
  handleCreateItem2: () => void;
  handleCreateFolder: () => void;
  handleCreateFolder2: () => void;
  handleDrop: (e: any) => void;
  handleDrop2: (e: any) => void;
}

const Sidebar = <T,>({
  isOpen,
  addItemButtonTitle,
  addItemButtonTitle2,
  side,
  items,
  items2,
  itemComponent,
  itemComponent2,
  folderComponent,
  folderComponent2,
  footerComponent,
  searchTerm,
  searchTerm2,
  handleSearchTerm,
  handleSearchTerm2,
  toggleOpen,
  handleCreateItem,
  handleCreateItem2,
  handleCreateFolder,
  handleCreateFolder2,
  handleDrop,
  handleDrop2,
}: Props<T>) => {
  const { t } = useTranslation('promptbar');

  const allowDrop = (e: any) => {
    e.preventDefault();
  };

  const highlightDrop = (e: any) => {
    e.target.style.background = '#343541';
  };

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

        <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 data.')}
              </span>
            </div>
          )}

        </div>

        <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={() => {
              handleCreateItem2();
              handleSearchTerm2('');
            }}
          >
            <IconPlus size={16} />
            {addItemButtonTitle2}
          </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={handleCreateFolder2}
          >
            <IconFolderPlus size={16} />
          </button>
        </div>
        <Search
          placeholder={t('Search...') || ''}
          searchTerm={searchTerm2}
          onSearch={handleSearchTerm2}
        />

        <div className="flex-grow overflow-auto">
          {items2?.length > 0 && (
            <div className="flex border-b border-white/20 pb-2">
              {folderComponent2}
            </div>
          )}

          {items2?.length > 0 ? (
            <div
              className="pt-2"
              onDrop={handleDrop2}
              onDragOver={allowDrop}
              onDragEnter={highlightDrop}
              onDragLeave={removeHighlight}
            >
              {itemComponent2}
            </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 data.')}
              </span>
            </div>
          )}

        </div>
        {footerComponent}
      </div>

      <CloseSidebarButton onClick={toggleOpen} side={side} />
    </div>
  ) : (
    <OpenSidebarButton onClick={toggleOpen} side={side} />
  );
};

export default Sidebar;
kirin-ri commented 8 months ago

import { IconFolderPlus, IconMistOff, IconPlus } from '@tabler/icons-react';
import { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';

import {
  CloseSidebarButton,
  OpenSidebarButton,
} from './components/OpenCloseButton';

import Search from '../Search';

interface Props<T> {
  isOpen: boolean;
  addItemButtonTitle: string;
  addItemButtonTitle2: string;
  side: 'left' | 'right';
  items: T[];
  items2: T[];
  itemComponent: ReactNode;
  itemComponent2: ReactNode;
  folderComponent: ReactNode;
  folderComponent2: ReactNode;
  footerComponent?: ReactNode;
  searchTerm: string;
  searchTerm2: string;
  handleSearchTerm: (searchTerm: string) => void;
  handleSearchTerm2: (searchTerm: string) => void;
  toggleOpen: () => void;
  handleCreateItem: () => void;
  handleCreateItem2: () => void;
  handleCreateFolder: () => void;
  handleCreateFolder2: () => void;
  handleDrop: (e: any) => void;
  handleDrop2: (e: any) => void;
}

const Sidebar = <T,>({
  isOpen,
  addItemButtonTitle,
  addItemButtonTitle2,
  side,
  items,
  items2,
  itemComponent,
  itemComponent2,
  folderComponent,
  folderComponent2,
  footerComponent,
  searchTerm,
  searchTerm2,
  handleSearchTerm,
  handleSearchTerm2,
  toggleOpen,
  handleCreateItem,
  handleCreateItem2,
  handleCreateFolder,
  handleCreateFolder2,
  handleDrop,
  handleDrop2,
}: Props<T>) => {
  const { t } = useTranslation('promptbar');

  const allowDrop = (e: any) => {
    e.preventDefault();
  };

  const highlightDrop = (e: any) => {
    e.target.style.background = '#343541';
  };

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

        <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 data.')}
              </span>
            </div>
          )}

        </div>

        <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={() => {
              handleCreateItem2();
              handleSearchTerm2('');
            }}
          >
            <IconPlus size={16} />
            {addItemButtonTitle2}
          </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={handleCreateFolder2}
          >
            <IconFolderPlus size={16} />
          </button>
        </div>
        <Search
          placeholder={t('Search...') || ''}
          searchTerm={searchTerm2}
          onSearch={handleSearchTerm2}
        />

        <div className="flex-grow overflow-auto">
          {items2?.length > 0 && (
            <div className="flex border-b border-white/20 pb-2">
              {folderComponent2}
            </div>
          )}

          {items2?.length > 0 ? (
            <div
              className="pt-2"
              onDrop={handleDrop2}
              onDragOver={allowDrop}
              onDragEnter={highlightDrop}
              onDragLeave={removeHighlight}
            >
              {itemComponent2}
            </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 data.')}
              </span>
            </div>
          )}

        </div>
        {footerComponent}
      </div>

      <CloseSidebarButton onClick={toggleOpen} side={side} />
    </div>
  ) : (
    <OpenSidebarButton onClick={toggleOpen} side={side} />
  );
};

export default Sidebar;

```import { IconFolderPlus, IconMistOff, IconPlus } from '@tabler/icons-react';
import { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';

import {
  CloseSidebarButton,
  OpenSidebarButton,
} from './components/OpenCloseButton';

import Search from '../Search';

interface Props<T> {
  isOpen: boolean;
  addItemButtonTitle: string;
  addItemButtonTitle2: string;
  side: 'left' | 'right';
  items: T[];
  items2: T[];
  itemComponent: ReactNode;
  itemComponent2: ReactNode;
  folderComponent: ReactNode;
  folderComponent2: ReactNode;
  footerComponent?: ReactNode;
  searchTerm: string;
  searchTerm2: string;
  handleSearchTerm: (searchTerm: string) => void;
  handleSearchTerm2: (searchTerm: string) => void;
  toggleOpen: () => void;
  handleCreateItem: () => void;
  handleCreateItem2: () => void;
  handleCreateFolder: () => void;
  handleCreateFolder2: () => void;
  handleDrop: (e: any) => void;
  handleDrop2: (e: any) => void;
}

const Sidebar = <T,>({
  isOpen,
  addItemButtonTitle,
  addItemButtonTitle2,
  side,
  items,
  items2,
  itemComponent,
  itemComponent2,
  folderComponent,
  folderComponent2,
  footerComponent,
  searchTerm,
  searchTerm2,
  handleSearchTerm,
  handleSearchTerm2,
  toggleOpen,
  handleCreateItem,
  handleCreateItem2,
  handleCreateFolder,
  handleCreateFolder2,
  handleDrop,
  handleDrop2,
}: Props<T>) => {
  const { t } = useTranslation('promptbar');

  const allowDrop = (e: any) => {
    e.preventDefault();
  };

  const highlightDrop = (e: any) => {
    e.target.style.background = '#343541';
  };

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

        <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 data.')}
              </span>
            </div>
          )}

        </div>

        <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={() => {
              handleCreateItem2();
              handleSearchTerm2('');
            }}
          >
            <IconPlus size={16} />
            {addItemButtonTitle2}
          </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={handleCreateFolder2}
          >
            <IconFolderPlus size={16} />
          </button>
        </div>
        <Search
          placeholder={t('Search...') || ''}
          searchTerm={searchTerm2}
          onSearch={handleSearchTerm2}
        />

        <div className="flex-grow overflow-auto">
          {items2?.length > 0 && (
            <div className="flex border-b border-white/20 pb-2">
              {folderComponent2}
            </div>
          )}

          {items2?.length > 0 ? (
            <div
              className="pt-2"
              onDrop={handleDrop2}
              onDragOver={allowDrop}
              onDragEnter={highlightDrop}
              onDragLeave={removeHighlight}
            >
              {itemComponent2}
            </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 data.')}
              </span>
            </div>
          )}

        </div>
        {footerComponent}
      </div>

      <CloseSidebarButton onClick={toggleOpen} side={side} />
    </div>
  ) : (
    <OpenSidebarButton onClick={toggleOpen} side={side} />
  );
};

export default Sidebar;
kirin-ri commented 8 months ago

Unhandled Runtime Error TypeError: Cannot destructure property 'dispatch' of '(0 , react__WEBPACK_IMPORTED_MODULE_1__.useContext)(...)' as it is undefined.

kirin-ri commented 8 months ago
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';
import { KBPrompts } from './KBPrompts';

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} */}
      <KBPrompts />

      <SettingDialog
        open={isSettingDialogOpen}
        onClose={() => {
          setIsSettingDialog(false);
        }}
      />
    </div>
  );
};
kirin-ri commented 8 months ago
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>
  );
};
kirin-ri commented 8 months ago

Type '(data: SupportedExportFormats) => void' is not assignable to type '() => void'. Target signature provides too few arguments. Expected 1 or more, but got 0.ts(2322) SettingDialog.tsx(16, 3): The expected type comes from property 'onImportConversations' which is declared here on type 'IntrinsicAttributes & Props'

kirin-ri commented 8 months ago

Type '(data: SupportedExportFormats) => void' is not assignable to type 'MouseEventHandler'. Types of parameters 'data' and 'event' are incompatible. Type 'MouseEvent<HTMLButtonElement, MouseEvent>' is not assignable to type 'SupportedExportFormats'.ts(2322) index.d.ts(1484, 9): The expected type comes from property 'onClick' which is declared here on type 'DetailedHTMLProps<ButtonHTMLAttributes, HTMLButtonElement>

kirin-ri commented 8 months ago
    <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>

            <div>
              {onImportConversations && (
                <Import onImport={onImportConversations} />
              )}

              {onExportData && (
                <SidebarButton
                  text={t('Export data')}
                  icon={<IconFileExport size={18} />}
                  onClick={() => onExportData()}
                />
              )}
            </div>

            <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>
kirin-ri commented 8 months ago
          const { answer } = await response.json();
          const updatedMessages: Message[] = [
            ...updatedConversation.messages,
            { role: 'assistant', content: answer },
          ];
kirin-ri commented 8 months ago
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 { ErrorMessageDiv } from './ErrorMessageDiv';
import { ModelSelect } from './ModelSelect';
import { SystemPrompt } from './SystemPrompt';
import { TemperatureSlider } from './Temperature';
import { MemoizedChatMessage } from './MemoizedChatMessage';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { KB_USER_PROMPT, KB_SYSTEM_PROMPT, AZURE_COGNITIVE_SEARCH_INDEX, KB_QUERY_SYSTEM_ROLE } from '@/utils/app/const';

interface Props {
  stopConversationRef: MutableRefObject<boolean>;
}

function getLocalStorage<S>(key: string, defaultValue: S) : S {
  const jsonValue = localStorage.getItem(key);
  if (jsonValue !== null) return JSON.parse(jsonValue);
  return defaultValue;
}

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);

  const [answer,setAnswer] = useState(null);
  const [currentMessage, setCurrentMessage] = useState<Message>();
  const [autoScrollEnabled, setAutoScrollEnabled] = useState<boolean>(true);
  const [showSettings, setShowSettings] = useState<boolean>(false);
  const [showScrollDownButton, setShowScrollDownButton] =
    useState<boolean>(false);
  const [plugin, setPlugin] = useState<Plugin | null>(null);

  const messagesEndRef = useRef<HTMLDivElement>(null);
  const chatContainerRef = useRef<HTMLDivElement>(null);
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  const handlePluginChange = ( p: Plugin ) => {
    setPlugin( p );
  }

  const handleSend = useCallback(
    async (message: Message, deleteCount = 0, plugin: Plugin | 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 if (plugin.id === 'qdrant-search') {
          const kbUserPrompt = getLocalStorage('kbUserPrompt', KB_USER_PROMPT );
          const kbSystemPrompt = getLocalStorage('kbSystemPrompt', KB_SYSTEM_PROMPT);
          const kbCogSearchIndex = getLocalStorage('kbCogSearchIndex', AZURE_COGNITIVE_SEARCH_INDEX.split(',')[0]);
          const kbSearchSystemPrompt = getLocalStorage('kbSearchSystemPrompt', KB_QUERY_SYSTEM_ROLE);
          const kbDebugMode = getLocalStorage('kbDebugMode', false);
          const kbSearchQueryEnabled = getLocalStorage('kbSearchQueryEnabled', true);

          body = JSON.stringify({
            ...chatBody,
            kbSearchQueryEnabled: kbSearchQueryEnabled,
            kbSearchSystemPrompt: kbSearchSystemPrompt,
            kbUserPrompt: kbUserPrompt,
            kbSystemPrompt: kbSystemPrompt,
            kbCogSearchIndex: kbCogSearchIndex,
            kbDebugMode: kbDebugMode
          });
        } else if (plugin.id === 'google-search') {
          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);
          const { error } = await response.json();
          alert( error );
          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();
          setAnswer(answer);
          const updatedMessages: Message[] = [
            ...updatedConversation.messages,
            { role: 'assistant', content: answer },
          ];
          if (updatedConversation.messages.length === 1) {
            const { content } = message;
            const customName =
              content.length > 30 ? content.substring(0, 30) + '...' : content;
            updatedConversation = {
              ...updatedConversation,
              name: customName,
            };
          }
          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: '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 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]);

  return (
    <div className="relative flex-1 overflow-hidden bg-white dark:bg-[#343541]">
      {!(apiKey || serverSideApiKeyIsSet) ? (
        <div className="mx-auto flex h-full w-[300px] flex-col justify-center space-y-6 sm:w-[600px]">
          <div className="text-center text-4xl font-bold text-black dark:text-white">
            Welcome to Chatbot UI
          </div>
          <div className="text-center text-lg text-black dark:text-white">
            <div className="mb-8">{`Chatbot UI is an open source clone of OpenAI's ChatGPT UI.`}</div>
            <div className="mb-2 font-bold">
              Important: Chatbot UI is 100% unaffiliated with OpenAI.
            </div>
          </div>
          <div className="text-center text-gray-500 dark:text-gray-400">
            <div className="mb-2">
              Chatbot UI allows you to plug in your API key to use this UI with
              their API.
            </div>
            <div className="mb-2">
              It is <span className="italic">only</span> used to communicate
              with their API.
            </div>
            <div className="mb-2">
              {t(
                'Please set your OpenAI API key in the bottom left of the sidebar.',
              )}
            </div>
            <div>
              {t("If you don't have an OpenAI API key, you can get one here: ")}
              <a
                href="https://platform.openai.com/account/api-keys"
                target="_blank"
                rel="noreferrer"
                className="text-blue-500 hover:underline"
              >
                openai.com
              </a>
            </div>
          </div>
        </div>
      ) : modelError ? (
        <ErrorMessageDiv error={modelError} />
      ) : (
        <>
          <div
            className="max-h-full overflow-x-hidden"
            ref={chatContainerRef}
            onScroll={handleScroll}
          >
            {selectedConversation?.messages.length === 0 ? (
              <>
                <div className="mx-auto flex flex-col space-y-5 md:space-y-10 px-3 pt-5 md:pt-12 sm:max-w-[600px]">
                  <div className="text-center text-3xl font-semibold text-gray-800 dark:text-gray-100">
                    {models.length === 0 ? (
                      <div>
                        <Spinner size="16px" className="mx-auto" />
                      </div>
                    ) : (
                      'Chatbot UI'
                    )}
                  </div>

                  {models.length > 0 && (
                    <div className="flex h-full flex-col space-y-4 rounded-lg border border-neutral-200 p-4 dark:border-neutral-600">
                      <ModelSelect />

                      <SystemPrompt
                        conversation={selectedConversation}
                        prompts={prompts}
                        onChangePrompt={(prompt) =>
                          handleUpdateConversation(selectedConversation, {
                            key: 'prompt',
                            value: prompt,
                          })
                        }
                      />

                      <TemperatureSlider
                        label={t('Temperature')}
                        onChangeTemperature={(temperature) =>
                          handleUpdateConversation(selectedConversation, {
                            key: 'temperature',
                            value: temperature,
                          })
                        }
                      />
                    </div>
                  )}
                </div>
              </>
            ) : (
              <>
                <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>
                )}

                {selectedConversation?.messages.map((message, index) => (
                  <MemoizedChatMessage
                    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,
                        plugin
                      );
                    }}
                  />
                ))}

                {loading && <ChatLoader />}

                <div
                  className="h-[162px] bg-white dark:bg-[#343541]"
                  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, plugin);
              }
            }}
            showScrollDownButton={showScrollDownButton}
            plugin={plugin}
            handlePluginChange={handlePluginChange}
          />
        </>
      )}
    </div>
  );
});
Chat.displayName = 'Chat';
kirin-ri commented 8 months ago
import { NextApiRequest, NextApiResponse } from 'next';
import { CognitiveSearchContent, KbChatBody } from '@/types/cogsearch';
import { getAnswerFromChatGPT } from './chat_complete';
import { AZURE_COGNITIVE_SEARCH_ENDPOINT, AZURE_COGNITIVE_SEARCH_CREDENTIAL } from '@/utils/app/const'
import endent from "endent";
import { OpenAIModels } from '@/types/openai';

const { SearchClient, AzureKeyCredential } = require("@azure/search-documents");

// a handler for Qdrant
export default async function qdrantHandler(
  req: NextApiRequest,
  res: NextApiResponse<any>,
): Promise<any> {
  try {
    const { model, messages, key, prompt, temperature, kbSearchQueryEnabled, kbSearchSystemPrompt, kbUserPrompt, kbSystemPrompt, kbCogSearchIndex, kbDebugMode } = req.body as KbChatBody;
    const userMessage = messages[messages.length - 1].content.trim(); // prompt from user

    const client = new SearchClient(
      AZURE_COGNITIVE_SEARCH_ENDPOINT,
      kbCogSearchIndex,
      new AzureKeyCredential(AZURE_COGNITIVE_SEARCH_CREDENTIAL)
    );

    // 1. get query from question using Chatbot AI

    let query;
    if (kbSearchQueryEnabled) {
      query = await getAnswerFromChatGPT(
        key,
        model,
        temperature,
        userMessage,
        kbSearchSystemPrompt
      );
    } else {
      query = userMessage;
    }

    // console.log("keywords:" + keywords);

    // 2. get a list of docs that are similar to the vector using Qdrant API

    for (const [key, value] of Object.entries(OpenAIModels)) {
      if (key === model.id) {
        model.tokenLimit = value.tokenLimit;
      }
    }

    const top = Math.min(Math.floor(model.tokenLimit / 1024), 8) - 1;
    // console.log('top:' + top);

    // const searchResult = await client.search(keyword.trim(), {top: 3}) ;
    const searchResult = await client.search(query, {
      queryType: 'semantic', 
      queryLanguage: "ja-JP", 
      semanticConfiguration: "senshin-semantic", //TODO need to be configurable.
      top: top});
    let sourcesWithText: CognitiveSearchContent[] = [];
    for await (const result of searchResult.results) {
      // console.log(result.score)
      const cogsearchSource = {
        id: result.document.id,
        title: result.document.title,
        source: result.document.source,
        content: result.document.content,
        score: result.score
      }
      sourcesWithText.push(cogsearchSource)
    }

    // console.log(sourcesWithText)

    // 3. get an answer from ChatGPT(OpenAI API) using the augmented prompt

    const userPrompt = endent`
    ${kbUserPrompt}

    Input:

    ${userMessage}

    Sources:

    ${sourcesWithText.map((source) => {
        return endent`
      [${source.title}] (${source.source}):
      ${source.content}
      `;
    }).join('\n\n')}
    `;

    const answerFromChatGPT = await getAnswerFromChatGPT(
      key,
      model,
      temperature,
      userPrompt,
      kbSystemPrompt
    );

    // console.log(answerFromChatGPT)

    let answer = answerFromChatGPT;

    if (kbDebugMode) {
      answer += '\n\n' + endent`
      --------
      ## User Prompt:

      ${userPrompt}

      ## Search Query:

      ${query}

      ## Reference:

      ${sourcesWithText.map((source) => {
        return endent`
        **ID:** ${source.id}  
        **Source:** ${source.source}  
        **Score:** ${source.score}  
        **Title**: ${source.title}  
        **Content:**  
        ${source.content}  
        `;
      }).join('\n\n')}
      `
    }

    console.log(answer)

    res.status(200).json({ answer });
} catch (error) {
    console.error(error);
    res.status(500).json({ error: `An error occurred: ${error}` });
  }
}
kirin-ri commented 8 months ago
import { IconFolderPlus, IconMistOff, IconPlus, IconFile } from '@tabler/icons-react';
import { ReactNode, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { IconArrowBarLeft, IconArrowBarRight } from '@tabler/icons-react';

import {
  CloseSidebarButton,
  OpenSidebarButton,
} from './components/OpenCloseButton';

interface Reference {
  id: string;
  title: string;
  description: string;
}

interface Props<T> {
  isOpen: boolean;
  side: 'left' | 'right';
  items: T[];
  toggleOpen: () => void;
}

const ReferenceSidebar = <T,>({
  isOpen,
  side,
  items,
  toggleOpen,
}: Props<T>) => {
  const { t } = useTranslation('promptbar');
  const [hoveredItemId, setHoveredItemId] = useState<string | null>(null);

  const allowDrop = (e: any) => {
    e.preventDefault();
  };

  const highlightDrop = (e: any) => {
    e.target.style.background = '#343541';
  };

  const removeHighlight = (e: any) => {
    e.target.style.background = 'none';
  };

  const itemsample = [
    {id:'1',title:'実績発電原子炉に係る新規制基準の考え方について.pdf',description:'…見直しを行うために「発電用軽水型原子炉の新規制基準に関する検討チーム」(以下「検討チーム」という。)を組織し、発電用軽水型原子炉の新規制基準策定のための検討を開始した。検討チーム…'},
    {id:'2',title:'実績発電原子炉に係る新規制基準の考え方について.pdf',description:'…見直しを行うために「発電用軽水型原子炉の新規制基準に関する検討チーム」(以下「検討チーム」という。)を組織し、発電用軽水型原子炉の新規制基準策定のための検討を開始した。検討チーム…'},
    {id:'3',title:'実績発電原子炉に係る新規制基準の考え方について.pdf',description:'…見直しを行うために「発電用軽水型原子炉の新規制基準に関する検討チーム」(以下「検討チーム」という。)を組織し、発電用軽水型原子炉の新規制基準策定のための検討を開始した。検討チーム…'},
    {id:'4',title:'実績発電原子炉に係る新規制基準の考え方について.pdf',description:'…見直しを行うために「発電用軽水型原子炉の新規制基準に関する検討チーム」(以下「検討チーム」という。)を組織し、発電用軽水型原子炉の新規制基準策定のための検討を開始した。検討チーム…'},
    {id:'5',title:'実績発電原子炉に係る新規制基準の考え方について.pdf',description:'…見直しを行うために「発電用軽水型原子炉の新規制基準に関する検討チーム」(以下「検討チーム」という。)を組織し、発電用軽水型原子炉の新規制基準策定のための検討を開始した。検討チーム…'},
  ]

  const titleStyle = {
    display: 'flex',
    alignItems: 'center',
    marginBottom: '30px',
    fontSize:'18px'
  }
  const headerStyle = {
    display: 'flex',
    justiftContent: 'space-between',
    alignItems:'center',
    fontSize: '15px',
    marginBottom: '15px',
    width: '100%',
  }
  const titleIcon = {
    display: 'flex',
    marginRight: '8px',
  }
  const listItemStyle = (isHovered: boolean) => ({
    marginBottom: '10px',
    border: '1px solid white',
    padding: '20px',
    borderRadius: '5px',
    backgroundColor: isHovered ? '#0056b3' : '#343541', // Change background color on hover
    transition: 'background-color 0.3s',
    maxWidth: '350px',
  });
  const listTitleStyle = {
    marginBottom: '10px',
  }
  return isOpen ? (
    <div>
      <div className={`fixed top-0 ${side}-0 z-40 flex h-full w-[400px] flex-none flex-col space-y-2 bg-[#202123] p-2 text-[14px] transition-all sm:relative sm:top-0`}>
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%', padding: '0 10px' }}>
          <div style={titleStyle}>
            <IconFile style={{ marginRight: '8px' }} />
            参照リスト
          </div>
          <div onClick={toggleOpen} style={titleStyle}>
            {side === 'right' ? <IconArrowBarRight /> : <IconArrowBarLeft />}
          </div>
        </div>
        <div  style={{overflowY:'auto',flex:1,padding:'0 10px'}}>
          <ul>
            {itemsample.map((item) => (
              <a href="test" target="_blank" rel="noopener noreferrer">
                <li
                style={listItemStyle(item.id === hoveredItemId)}
                onMouseEnter={() => setHoveredItemId(item.id)}
                onMouseLeave={() => setHoveredItemId(null)}
                >
                  <h3 style={listTitleStyle}>{item.title}</h3>
                  <p>{item.description}</p>
                </li>
              </a>
            ))}
          </ul>
        </div>
      </div>
    </div>
  ) : (
    <OpenSidebarButton onClick={toggleOpen} side={side} />
  );
};

export default ReferenceSidebar;
kirin-ri commented 8 months ago
import { IconFolderPlus, IconMistOff, IconPlus, IconFile } from '@tabler/icons-react';
import { ReactNode, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { IconArrowBarLeft, IconArrowBarRight } from '@tabler/icons-react';

import {
  CloseSidebarButton,
  OpenSidebarButton,
} from './components/OpenCloseButton';

interface Reference {
  id: string;
  title: string;
  description: string;
}

interface Props<T> {
  isOpen: boolean;
  side: 'left' | 'right';
  items: T[];
  toggleOpen: () => void;
}

const ReferenceSidebar = <T,>({
  isOpen,
  side,
  items,
  toggleOpen,
}: Props<T>) => {
  const { t } = useTranslation('promptbar');
  const [hoveredItemId, setHoveredItemId] = useState<string | null>(null);

  const allowDrop = (e: any) => {
    e.preventDefault();
  };

  const highlightDrop = (e: any) => {
    e.target.style.background = '#343541';
  };

  const removeHighlight = (e: any) => {
    e.target.style.background = 'none';
  };

  const itemsample = [
    {id:'1',title:'実績発電原子炉に係る新規制基準の考え方について.pdf',description:'…見直しを行うために「発電用軽水型原子炉の新規制基準に関する検討チーム」(以下「検討チーム」という。)を組織し、発電用軽水型原子炉の新規制基準策定のための検討を開始した。検討チーム…'},
    {id:'2',title:'実績発電原子炉に係る新規制基準の考え方について.pdf',description:'…見直しを行うために「発電用軽水型原子炉の新規制基準に関する検討チーム」(以下「検討チーム」という。)を組織し、発電用軽水型原子炉の新規制基準策定のための検討を開始した。検討チーム…'},
    {id:'3',title:'実績発電原子炉に係る新規制基準の考え方について.pdf',description:'…見直しを行うために「発電用軽水型原子炉の新規制基準に関する検討チーム」(以下「検討チーム」という。)を組織し、発電用軽水型原子炉の新規制基準策定のための検討を開始した。検討チーム…'},
    {id:'4',title:'実績発電原子炉に係る新規制基準の考え方について.pdf',description:'…見直しを行うために「発電用軽水型原子炉の新規制基準に関する検討チーム」(以下「検討チーム」という。)を組織し、発電用軽水型原子炉の新規制基準策定のための検討を開始した。検討チーム…'},
    {id:'5',title:'実績発電原子炉に係る新規制基準の考え方について.pdf',description:'…見直しを行うために「発電用軽水型原子炉の新規制基準に関する検討チーム」(以下「検討チーム」という。)を組織し、発電用軽水型原子炉の新規制基準策定のための検討を開始した。検討チーム…'},
  ]

  const titleStyle = {
    display: 'flex',
    alignItems: 'center',
    marginBottom: '30px',
    fontSize:'18px'
  }
  const headerStyle = {
    display: 'flex',
    justiftContent: 'space-between',
    alignItems:'center',
    fontSize: '15px',
    marginBottom: '15px',
    width: '100%',
  }
  const titleIcon = {
    display: 'flex',
    marginRight: '8px',
  }
  const listItemStyle = (isHovered: boolean) => ({
    marginBottom: '10px',
    border: '1px solid white',
    padding: '20px',
    borderRadius: '5px',
    backgroundColor: isHovered ? '#0056b3' : '#343541', // Change background color on hover
    transition: 'background-color 0.3s',
    maxWidth: '350px',
  });
  const listTitleStyle = {
    marginBottom: '10px',
  }
  return isOpen ? (
    <div>
      <div className={`fixed top-0 ${side}-0 z-40 flex h-full w-[400px] flex-none flex-col space-y-2 bg-[#202123] p-2 text-[14px] transition-all sm:relative sm:top-0`}>
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%', padding: '0 10px' }}>
          <div style={titleStyle}>
            <IconFile style={{ marginRight: '8px' }} />
            参照リスト
          </div>
          <div onClick={toggleOpen} style={titleStyle}>
            {side === 'right' ? <IconArrowBarRight /> : <IconArrowBarLeft />}
          </div>
        </div>
        <div  style={{overflowY:'auto',flex:1,padding:'0 10px'}}>
          <ul>
            {itemsample.map((item) => (
              <a href="index01/実用発電用原子炉に係る新規制基準の考え方について.pdf" target="_blank" rel="noopener noreferrer">
                <li
                style={listItemStyle(item.id === hoveredItemId)}
                onMouseEnter={() => setHoveredItemId(item.id)}
                onMouseLeave={() => setHoveredItemId(null)}
                >
                  <h3 style={listTitleStyle}>{item.title}</h3>
                  <p>{item.description}</p>
                </li>
              </a>
            ))}
          </ul>
        </div>
      </div>
    </div>
  ) : (
    <OpenSidebarButton onClick={toggleOpen} side={side} />
  );
};

export default ReferenceSidebar;
kirin-ri commented 8 months ago

https://caramlops-saas-0.westus.cloudapp.azure.com/file/test/index01/%E5%AE%9F%E7%94%A8%E7%99%BA%E9%9B%BB%E7%94%A8%E5%8E%9F%E5%AD%90%E7%82%89%E3%81%AB%E4%BF%82%E3%82%8B%E6%96%B0%E8%A6%8F%E5%88%B6%E5%9F%BA%E6%BA%96%E3%81%AE%E8%80%83%E3%81%88%E6%96%B9%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6.pdf

kirin-ri commented 8 months ago
import { OpenAIModel } from './openai';

export interface Reference {
  id: string;
  name: string;
  description: string;
}
kirin-ri commented 8 months ago
            {items.map((item,index) => (
              <a href={'https://caramlops-saas-0.westus.cloudapp.azure.com/file/test/index01/実用発電用原子炉に係る新規制基準の考え方について.pdf'} target="_blank" rel="noopener noreferrer">
                <li
                style={listItemStyle(index === hoveredItemId)}
                onMouseEnter={() => setHoveredItemId(index)}
                onMouseLeave={() => setHoveredItemId(null)}
                >
                  <h3 style={listTitleStyle}>{item.title}</h3>
                  <p>{item.description}</p>
                </li>
              </a>
            ))}
          </ul>