kirin-ri / memo

0 stars 0 forks source link

322 #16

Open kirin-ri opened 3 months ago

kirin-ri commented 3 months ago
import React, { useEffect, useRef, useState } from 'react';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf';
import 'pdfjs-dist/legacy/build/pdf.worker.entry';

interface PDFPreviewProps {
  fileUrl: string;
  pageNumber: number;
}

const PDFPreview: React.FC<PDFPreviewProps> = ({ fileUrl, pageNumber }) => {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [imageSrc, setImageSrc] = useState<string | null>(null);

  useEffect(() => {
    const loadPdf = async () => {
      if (imageSrc) return; // 如果已有图片,则不重新渲染PDF

      pdfjsLib.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.js`;

      try {
        const pdf = await pdfjsLib.getDocument(fileUrl).promise;
        const page = await pdf.getPage(pageNumber);
        const scale = 0.1;
        const viewport = page.getViewport({ scale });
        const canvas = canvasRef.current;

        if (canvas) {
          const context = canvas.getContext('2d');
          if (context) {
            canvas.height = viewport.height;
            canvas.width = viewport.width;

            const renderContext = {
              canvasContext: context,
              viewport,
            };
            await page.render(renderContext).promise;

            // 将canvas内容转换为图片URL
            const imageDataUrl = canvas.toDataURL();
            setImageSrc(imageDataUrl); // 保存图片URL以便后续使用
          }
        }
      } catch (error) {
        console.error("Error loading PDF: ", error);
      }
    };

    loadPdf();
  }, [fileUrl, pageNumber, imageSrc]); // 添加imageSrc作为依赖项

  // 如果有图片URL,直接展示图片,否则展示canvas
  return imageSrc ? (
    <img src={imageSrc} alt={`Page ${pageNumber}`} style={{width:'100%',height:'auto'}}/>
  ) : (
    <canvas ref={canvasRef}></canvas>
  );
};

export default PDFPreview;
kirin-ri commented 3 months ago
import { IconFile } from '@tabler/icons-react';
import { useState } from 'react';
import { IconArrowBarLeft, IconArrowBarRight } from '@tabler/icons-react';
import { Reference } from '@/types/reference';
import {
  OpenSidebarButton,
} from './components/OpenCloseButton';
import PDFPreview from '../Referencebar/PDFPreview';

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

const ReferenceSidebar = <T,>({
  isOpen,
  side,
  items,
  toggleOpen,
}: Props<T>) => {
  items.map((item,index) =>{
    console.log(item)
  })
  const [hoveredItemId, setHoveredItemId] = useState<number | null>(null);

  const titleStyle = {
    display: 'flex',
    alignItems: 'center',
    marginBottom: '20px',
    fontSize:'18px'
  }

  const listTitleStyle = {
    marginBottom: '10px',
    width: '100',
  }
  const listItemStyle = (isHovered: boolean) => ({
    display: 'flex',
    flexDirection: 'column' as 'column', 
    marginBottom: '10px',
    border: '1px solid white',
    padding: '20px',
    borderRadius: '5px',
    backgroundColor: isHovered ? '#0056b3' : '#343541',
    transition: 'background-color 0.3s',
    maxWidth: '400px',
    width: '100%',
    overflow: 'hidden', 
  });

  const titleAndContentContainerStyle = {
    display: 'flex',
    flexDirection: 'row' as 'row', 
    alignItems: 'flex-start', 
  };

  const contentStyle = {
    flex:'1 1 70%',
    display: '-webkit-box',
    WebkitBoxOrient: 'vertical' as 'vertical',
    WebkitLineClamp: 7, 
    overflow: 'hidden',
    textOverflow: 'ellipsis',
  };

  const pdfPreviewStyle = {
    flex:'1 1 30%',
    marginRight: '20px', 
  };

  const getPageNumberFromTitle = (title: string) => {
    const match = title.match(/\((\d+)[,)]/);
    return match ? `#page=${match[1]}` : '#page=1';
  };
  const extractPageNumberFromTitle = (title: string): number => {
    const match = title.match(/\((\d+)[,)]/);
    return match ? parseInt(match[1], 10) : 1;
  };  
  // const itemsample = [
  //   {source:'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',title:'実用発電原子炉に係る新規制基準の考え方について.pdf(30)',content:'日の原子力規制委員会に提出された論点のうちの残84された論点(例えば、新設炉と既設炉で目標値を分けるべきか否かなど)に関する議論を含め、安全目標に関する議論は、継続的な安全性向上を目指す原子力規制委員会として、今後とも引き続き検討を進めていくものとする。(3)新規制基準と安全目標の関係について原子力規制委員会は、安全目標は、基準ではなく規制を進めていく上で達成を目指す目標であると位置付けた。そして、原子炉等規制法の改正により新設された43条の3の29(発電用原子炉施設の安全性の向上のための評価)により、発電用原子炉設置者は施設定期検査終了後6ヶ月以内に自ら、安全性の向上のための評価を実施し、その結果を原子力規制委員会に届け出ることとなる。この安全性向上のための評価には、炉心損傷頻度、格納容器機能喪失頻度及びセシウム137の放出量が100テラベクレルを超えるような事故の発生頻度の評価が含まれており、原子力規制委員会は安全目標を参考にこの評価結果を踏まえ、必要な場合には、'},
  //   {source:'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',title:'実用発電原子炉に係る新規制基準の考え方について.pdf(20,32)',content:'規制委員会への届出及び要旨の公表が義務付けられている(同条3項)。また、内閣総理大臣及び原子力規制委員会は、原子力事業者防災業務計画が原子力災害の発生若しくは拡大を防止するために十分でないと認めるときは、原子力事業者に対し、上記計画の作成又は修正を命ずることができる(同条4項)。そして、原子力事業者が上記命令に違反した場合、原子力規制委員会は、発電用原子炉の設置許可の取消し又は1年以内の期間を定めてその運転の停止を命ずることができる。なお、原子力規制委員会は、届出のあった原子力事業者防災業務計画について、順次公表を行っている。77図1国による避難計画等の具体化・充実化支援等の全体図78§22-6安全目標と新規制基準の関係1相対的安全の考え方に立脚した原子力分野における安全目標本資料「§11-2」で述べたとおり、科学技術分野においては絶対的な安全性は達成することも要求することもできないものであるから、万が一、重大事故が発生したときは、当該原子炉施設の従業員やその周辺住民等の生命、身体に重大な危害を及'},
  //   {source:'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',title:'実用発電原子炉に係る新規制基準の考え方について.pdf()',content:'規制委員会への届出及び要旨の公表が義務付けられている(同条3項)。また、内閣総理大臣及び原子力規制委員会は、原子力事業者防災業務計画が原子力災害の発生若しくは拡大を防止するために十分でないと認めるときは、原子力事業者に対し、上記計画の作成又は修正を命ずることができる(同条4項)。そして、原子力事業者が上記命令に違反した場合、原子力規制委員会は、発電用原子炉の設置許可の取消し又は1年以内の期間を定めてその運転の停止を命ずることができる。なお、原子力規制委員会は、届出のあった原子力事業者防災業務計画について、順次公表を行っている。77図1国による避難計画等の具体化・充実化支援等の全体図78§22-6安全目標と新規制基準の関係1相対的安全の考え方に立脚した原子力分野における安全目標本資料「§11-2」で述べたとおり、科学技術分野においては絶対的な安全性は達成することも要求することもできないものであるから、万が一、重大事故が発生したときは、当該原子炉施設の従業員やその周辺住民等の生命、身体に重大な危害を及'},
  // ]
  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>
            {items.map((item, index) => (
              <a href={`${item.source}?v=timestamp=${new Date().getTime()}${getPageNumberFromTitle(item.title)}`} target="_blank" rel="noopener noreferrer">
                <li
                  style={listItemStyle(index === hoveredItemId)}
                  onMouseEnter={() => setHoveredItemId(index)}
                  onMouseLeave={() => setHoveredItemId(null)}
                >
                  <div style={listTitleStyle}>{item.title}</div>
                  <div style={titleAndContentContainerStyle}>
                    <div style={pdfPreviewStyle}>
                      <PDFPreview fileUrl={item.source} pageNumber={extractPageNumberFromTitle(item.title)}/>
                    </div>
                    <div style={contentStyle}>
                      <p>{item.content}</p>
                    </div>
                  </div>
                </li>
              </a>
            ))}
          </ul>
        </div>
      </div>
    </div>
  ) : (
    <OpenSidebarButton onClick={toggleOpen} side={side} />
  );
};

export default ReferenceSidebar;
kirin-ri commented 3 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';
import { Reference,ReferenceList } from '@/types/reference'; 

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,
      references,
      currentReference,
    },
    handleUpdateConversation,
    dispatch: homeDispatch,
  } = useContext(HomeContext);

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

          let addflg = false;
          for(let i = 0 ; i < references.length ; i ++){
            if(references[i].conversationId === selectedConversation.id){
              references[i].references = answer.answers.ReferenceSource
              addflg = true;
              break;
            }
          }
          if(!addflg){
            references.push({'conversationId':selectedConversation.id,'references':answer.answers.ReferenceSource});
          }
          homeDispatch({field :'references', value:references})
          homeDispatch({field :'currentReference', value:answer.answers.ReferenceSource})
          const updatedMessages: Message[] = [
            ...updatedConversation.messages,
            { role: 'assistant', content: answer.answers.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 3 months ago
import React, { useEffect, useRef, useState } from 'react';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf';
import 'pdfjs-dist/legacy/build/pdf.worker.entry';

interface PDFPreviewProps {
  fileUrl: string;
  pageNumber: number;
}

const PDFPreview: React.FC<PDFPreviewProps> = ({ fileUrl, pageNumber }) => {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [imageSrc, setImageSrc] = useState<string | null>(null);

  useEffect(() => {
    let isActive = true; // 用于跟踪组件的挂载状态

    const loadPdf = async () => {
      pdfjsLib.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.js`;

      try {
        const pdf = await pdfjsLib.getDocument(fileUrl).promise;
        const page = await pdf.getPage(pageNumber);
        const viewport = page.getViewport({ scale: 1.5 });
        const canvas = canvasRef.current;

        if (canvas && isActive) {
          const context = canvas.getContext('2d');
          if (context) {
            canvas.height = viewport.height;
            canvas.width = viewport.width;

            const renderContext = {
              canvasContext: context,
              viewport,
            };
            await page.render(renderContext).promise;

            if (isActive) {
              const imageDataUrl = canvas.toDataURL();
              setImageSrc(imageDataUrl); // 保存图片URL以便后续使用
            }
          }
        }
      } catch (error) {
        console.error("Error loading PDF: ", error);
      }
    };

    setImageSrc(null); // 清除之前的图片,触发重新渲染
    loadPdf();

    return () => {
      isActive = false; // 在组件卸载时更新状态,避免设置已卸载组件的状态
    };
  }, [fileUrl, pageNumber]); // 监听 fileUrl 和 pageNumber 的变化

  // 如果有图片URL,直接展示图片,否则展示canvas
  return imageSrc ? (
    <img src={imageSrc} alt={`Page ${pageNumber}`} style={{ width: '100%', height: 'auto' }} />
  ) : (
    <canvas ref={canvasRef} style={{ width: '100%', height: 'auto' }}></canvas>
  );
};

export default PDFPreview;
kirin-ri commented 3 months ago
import React, { useEffect, useRef, useState } from 'react';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf';
import 'pdfjs-dist/legacy/build/pdf.worker.entry';

interface PDFPreviewProps {
  fileUrl: string;
  pageNumber: number;
}

const PDFPreview: React.FC<PDFPreviewProps> = ({ fileUrl, pageNumber }) => {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [imageSrc, setImageSrc] = useState<string | null>(null);

  useEffect(() => {
    let isActive = true; 

    const loadPdf = async () => {
      pdfjsLib.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.js`;

      try {
        const pdf = await pdfjsLib.getDocument(fileUrl).promise;
        const page = await pdf.getPage(pageNumber);
        const viewport = page.getViewport({ scale: 0.5 });
        const canvas = canvasRef.current;

        if (canvas && isActive) {
          const context = canvas.getContext('2d');
          if (context) {
            canvas.height = viewport.height;
            canvas.width = viewport.width;

            const renderContext = {
              canvasContext: context,
              viewport,
            };
            await page.render(renderContext).promise;

            if (isActive) {
              const imageDataUrl = canvas.toDataURL();
              setImageSrc(imageDataUrl); 
            }
          }
        }
      } catch (error) {
        console.error("Error loading PDF: ", error);
      }
    };

    setImageSrc(null); 
    loadPdf();

    return () => {
      isActive = false; 
    };
  }, [fileUrl, pageNumber]); 

  return imageSrc ? (
    <img src={imageSrc} alt={`Page ${pageNumber}`} style={{ width: '100%', height: 'auto' }} />
  ) : (
    <canvas ref={canvasRef} style={{ width: '100%', height: 'auto' }}></canvas>
  );
};

export default PDFPreview;
kirin-ri commented 3 months ago

このPDFPreviewコンポーネントは、指定されたPDFファイルの指定ページをプレビューとして表示します。pdfjs-distライブラリを利用してPDFを読み込み、キャンバスにページをレンダリング後、そのキャンバスを画像としてウェブページ上に表示します。ReactのuseEffectフックを使用してPDFの読み込みとレンダリングを行い、useStateで画像のURLを、useRefでキャンバス要素への参照を管理します。読み込み中はキャンバスを、読み込み後はキャンバスの内容を画像として表示する仕組みです。

kirin-ri commented 3 months ago
import { IconFile } from '@tabler/icons-react';
import { useState } from 'react';
import { IconArrowBarLeft, IconArrowBarRight } from '@tabler/icons-react';
import { Reference } from '@/types/reference';
import {
  OpenSidebarButton,
} from './components/OpenCloseButton';
import PDFPreview from '../Referencebar/PDFPreview';

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

const ReferenceSidebar = <T,>({
  isOpen,
  side,
  items,
  toggleOpen,
}: Props<T>) => {
  items.map((item,index) =>{
    console.log(item)
  })
  const [hoveredItemId, setHoveredItemId] = useState<number | null>(null);

  const titleStyle = {
    display: 'flex',
    alignItems: 'center',
    marginBottom: '20px',
    fontSize:'18px'
  }

  const listTitleStyle = {
    marginBottom: '10px',
    width: '100',
  }
  const listItemStyle = (isHovered: boolean) => ({
    display: 'flex',
    flexDirection: 'column' as 'column', 
    marginBottom: '10px',
    border: '1px solid white',
    padding: '20px',
    borderRadius: '5px',
    backgroundColor: isHovered ? '#0056b3' : '#343541',
    transition: 'background-color 0.3s',
    maxWidth: '400px',
    width: '100%',
    overflow: 'hidden', 
  });

  const titleAndContentContainerStyle = {
    display: 'flex',
    flexDirection: 'row' as 'row', 
    alignItems: 'flex-start', 
  };

  const contentStyle = {
    flex:'1 1 70%',
    display: '-webkit-box',
    WebkitBoxOrient: 'vertical' as 'vertical',
    WebkitLineClamp: 7, 
    overflow: 'hidden',
    textOverflow: 'ellipsis',
  };

  const pdfPreviewStyle = {
    flex:'1 1 30%',
    marginRight: '20px', 
  };

  const getPageNumberFromTitle = (title: string) => {
    const match = title.match(/\((\d+)[,)]/);
    return match ? `#page=${match[1]}` : '#page=1';
  };
  const extractPageNumberFromTitle = (title: string): number => {
    const match = title.match(/\((\d+)[,)]/);
    return match ? parseInt(match[1], 10) : 1;
  };  

  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>
            {items.map((item, index) => (
              <a href={`${item.source}?v=timestamp=${new Date().getTime()}${getPageNumberFromTitle(item.title)}`} target="_blank" rel="noopener noreferrer">
                <li
                  style={listItemStyle(index === hoveredItemId)}
                  onMouseEnter={() => setHoveredItemId(index)}
                  onMouseLeave={() => setHoveredItemId(null)}
                >
                  <div style={listTitleStyle}>{item.title}</div>
                  <div style={titleAndContentContainerStyle}>
                    <div style={pdfPreviewStyle}>
                      <PDFPreview fileUrl={item.source} pageNumber={extractPageNumberFromTitle(item.title)}/>
                    </div>
                    <div style={contentStyle}>
                      <p>{item.content}</p>
                    </div>
                  </div>
                </li>
              </a>
            ))}
          </ul>
        </div>
      </div>
    </div>
  ) : (
    <OpenSidebarButton onClick={toggleOpen} side={side} />
  );
};

export default ReferenceSidebar;
kirin-ri commented 3 months ago

ReferenceSidebarコンポーネントは、開閉可能なサイドバー形式で参照資料のリストを表示し、各資料にPDFプレビュー機能を提供します。このサイドバーはisOpenで開閉状態を管理し、sideで表示位置(左または右)を指定します。itemsには参照資料の情報が含まれ、ユーザーがリンクをクリックすると新しいタブで該当資料を開きます。サイドバー内で資料アイテムにマウスをホバーすると視覚的なフィードバックが提供され、toggleOpen関数でサイドバーの表示・非表示を切り替えられます。このコンポーネントは、参照資料を効率的に確認できるインターフェースをユーザーに提供することを目的としています。

kirin-ri commented 3 months ago

・SCRUM-23画面実装ー参照リスト枠 新規:chatbot-ui/componets/Referencebar 新規:chatbot-ui/compoents/Sidebar/Referencebar.tsx 開閉可能なサイドバー形式で参照資料のリストを表示し、各資料にPDFプレビュー機能を提供する。 サイドバーはisOpenで開閉状態を管理し、sideで表示位置(左または右)を指定する。 itemsには参照資料の情報が含まれ、ユーザーがリンクをクリックすると新しいタブで該当資料を開く。 サイドバー内で資料アイテムにマウスをホバーすると視覚的なフィードバックが提供される。

・SCRUM-46参照リストのテキスト量を半分表示 修正chatbot-ui/compoents/Sidebar/Referencebar.tsx 60行目 const contentStyle = { flex:'1 1 70%', display: '-webkit-box', WebkitBoxOrient: 'vertical' as 'vertical', WebkitLineClamp: 7, overflow: 'hidden', textOverflow: 'ellipsis', }; WebkitLineClampの値により行数制限。

・SCRUM-48左側プロンプト、SCRUM-23画面実装ー会話履歴を設定に移植 修正chatbot-ui/compoents/Sidebar/Sidebar.tsx サイドバーPropsにプロンプト用の変数を追加 会話履歴のImportおよびExport導入 修正/chatbot-ui/components/Chatbar/components/ChatbarSettings.tsx 既存の会話履歴のImportおよびExportをコメントアウト

・SCRUM-42ドキュメント画像取得、SCRUM-39対象ページが表示 修正:chatbot-ui/components/Sidebar/ReferenceSidebar.tsx 107行目 PDF機能を参照リストの枠ごとに呼び出す 新規:chatbot-ui/components/Referencebar/PDFPreview.tsx PDFPreviewコンポーネントは、指定されたPDFファイルの指定ページをプレビューとして表示する。 pdfjs-distライブラリを利用してPDFを読み込み、キャンバスにページをレンダリング後、そのキャンバスを画像としてウェブページ上に表示する。 useEffectフックを使用してPDFの読み込みとレンダリングを行い、useStateで画像のURLを、useRefでキャンバス要素への参照を管理する。 読み込み中はキャンバスを、読み込み後はキャンバスの内容を画像として表示する仕組みである。

kirin-ri commented 3 months ago

SCRUM-23: 参照リスト枠の画面実装 概要 実装内容: 新規にchatbot-ui/components/Referencebarおよびchatbot-ui/components/Sidebar/Referencebar.tsxを作成し、開閉可能なサイドバー形式で参照資料のリストを表示。各資料にはPDFプレビュー機能が提供される。 機能: サイドバーはisOpenプロパティによって開閉状態が管理され、sideプロパティで表示位置(左または右)が指定される。itemsプロパティには参照資料の情報が含まれ、リンクをクリックすると新しいタブで資料が開かれる。サイドバー内で資料アイテムにマウスをホバーすると視覚的なフィードバックが提供される。 SCRUM-46: 参照リストのテキスト量を半分に表示 変更内容 対象ファイル: chatbot-ui/components/Sidebar/Referencebar.tsxの60行目。 実装詳細: contentStyleのWebkitLineClampの値を調整することで、表示されるテキストの行数が制限されるよう変更された。 SCRUM-48: 左側プロンプトの実装と会話履歴の設定への移植 変更内容 修正対象: chatbot-ui/components/Sidebar/Sidebar.tsxにプロンプト用の変数を追加し、会話履歴のImport及びExport機能を導入。また、chatbot-ui/components/Chatbar/components/ChatbarSettings.tsxでは、既存の会話履歴のImport及びExport機能をコメントアウトした。 SCRUM-42 & SCRUM-39: ドキュメント画像取得と対象ページ表示 実装詳細 修正箇所: chatbot-ui/components/Sidebar/ReferenceSidebar.tsxの107行目でPDF機能を参照リストの枠ごとに呼び出すよう変更。 新規実装: chatbot-ui/components/Referencebar/PDFPreview.tsxを新規作成。このコンポーネントは、pdfjs-distライブラリを利用して指定されたPDFファイルの指定ページをプレビュー表示する。useEffectフックを使用してPDFの読み込みとレンダリングを行い、useStateで画像のURLを、useRefでキャンバス要素への参照を管理する。読み込み中はキャンバスを表示し、読み込み完了後はキャンバスの内容を画像として表示する。 この文書は、各タスクの目的、実装の詳細、およびそれに伴う変更を明確に説明しています。各セクションはタスクの識別子で始まり、実装の概要または変更内容を簡潔に述べています。これにより、プロジェクトの進行状況を追跡しやすくなっています。