Просмотр исходного кода

Merge pull request #9204 from weseek/imprv/refactor-chat-modal

imprv: Knowledge assistant chat window
mergify[bot] 1 год назад
Родитель
Сommit
ecc4a46b67

+ 5 - 0
.changeset/odd-ladybugs-unite.md

@@ -0,0 +1,5 @@
+---
+'@growi/core-styles': minor
+---
+
+add $growi-ai-purple color

+ 6 - 0
apps/app/public/static/locales/en_US/translation.json

@@ -484,6 +484,12 @@
     "latest_revision": "theirs",
     "selected_editable_revision": "Selected Page Body (Editable)"
   },
+  "modal_aichat": {
+    "title": "Knowledge Assistant",
+    "title_beta_label": "(Beta)",
+    "placeholder": "Ask me anything.",
+    "caution_against_hallucination": "Please verify the information and check the sources."
+  },
   "link_edit": {
     "edit_link": "Edit Link",
     "set_link_and_label": "Set link and label",

+ 6 - 0
apps/app/public/static/locales/fr_FR/translation.json

@@ -478,6 +478,12 @@
     "latest_revision": "les autres",
     "selected_editable_revision": "Corps de page sélectionné (Modifiable)"
   },
+  "modal_aichat": {
+    "title": "Assistant de Connaissance",
+    "title_beta_label": "(Bêta)",
+    "placeholder": "Demandez-moi n'importe quoi.",
+    "caution_against_hallucination": "Veuillez vérifier les informations et consulter les sources."
+  },
   "link_edit": {
     "edit_link": "Modifier lien",
     "set_link_and_label": "Ajouter lien et étiquette",

+ 6 - 0
apps/app/public/static/locales/ja_JP/translation.json

@@ -517,6 +517,12 @@
     "latest_revision": "最新の本文",
     "selected_editable_revision": "保存するページ本文(編集可能)"
   },
+  "modal_aichat": {
+    "title": "ナレッジアシスタント",
+    "title_beta_label": "(ベータ)",
+    "placeholder": "ききたいことを入力してください",
+    "caution_against_hallucination": "情報が正しいか出典を確認しましょう"
+  },
   "link_edit": {
     "edit_link": "リンク編集",
     "set_link_and_label": "リンク情報",

+ 6 - 0
apps/app/public/static/locales/zh_CN/translation.json

@@ -473,6 +473,12 @@
     "latest_revision": "最新页面正文",
     "selected_editable_revision": "选定的可编辑页面正文"
   },
+  "modal_aichat": {
+    "title": "知识助手",
+    "title_beta_label": "(测试版)",
+    "placeholder": "问我任何问题。",
+    "caution_against_hallucination": "请核实信息并检查来源。"
+  },
   "link_edit": {
     "edit_link": "Edit Link",
     "set_link_and_label": "Set link and label",

+ 0 - 23
apps/app/src/client/components/RagSearch/MessageCard.tsx

@@ -1,23 +0,0 @@
-import ReactMarkdown from 'react-markdown';
-
-type Props = {
-  children?: string,
-  right?: boolean,
-}
-
-export const MessageCard = (props: Props): JSX.Element => {
-  const { children, right } = props;
-
-  const alignClass = right ? 'align-self-end bg-success-subtle' : 'align-self-start';
-  const bgClass = right ? 'bg-info-subtle' : '';
-
-  return (
-    <div className={`card d-inline-flex ${alignClass} ${bgClass}`} style={{ maxWidth: '75%' }}>
-      <div className="card-body">
-        { children != null && children.length > 0 && (
-          <ReactMarkdown>{children}</ReactMarkdown>
-        ) }
-      </div>
-    </div>
-  );
-};

+ 2 - 2
apps/app/src/components/Layout/BasicLayout.tsx

@@ -34,7 +34,7 @@ const DeleteBookmarkFolderModal = dynamic(
   () => import('~/client/components/DeleteBookmarkFolderModal').then(mod => mod.DeleteBookmarkFolderModal), { ssr: false },
 );
 const SearchModal = dynamic(() => import('../../features/search/client/components/SearchModal'), { ssr: false });
-const RagSearchModal = dynamic(() => import('~/client/components/RagSearch/RagSearchModal'), { ssr: false });
+const AiChatModal = dynamic(() => import('~/features/openai/chat/components/AiChatModal').then(mod => mod.AiChatModal), { ssr: false });
 
 type Props = {
   children?: ReactNode
@@ -67,7 +67,7 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
       <DeleteBookmarkFolderModal />
       <PutbackPageModal />
       <SearchModal />
-      <RagSearchModal />
+      <AiChatModal />
 
       <PagePresentationModal />
       <HotkeysManager />

+ 5 - 0
apps/app/src/client/components/RagSearch/RagSearchModal.module.scss → apps/app/src/features/openai/chat/components/AiChatModal/AiChatModal.module.scss

@@ -1,4 +1,5 @@
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
+@use '@growi/core-styles/scss/variables/growi-official-colors';
 @use '@growi/ui/scss/atoms/btn-muted';
 
 .rag-search-modal :global {
@@ -15,6 +16,10 @@
 
 // == Colors
 .rag-search-modal :global {
+  .growi-ai-chat-icon {
+    color: growi-official-colors.$growi-ai-purple;
+  }
+
   .btn-submit {
     @include btn-muted.colorize(bs.$purple, bs.$purple);
   }

+ 78 - 58
apps/app/src/client/components/RagSearch/RagSearchModal.tsx → apps/app/src/features/openai/chat/components/AiChatModal/AiChatModal.tsx

@@ -2,7 +2,10 @@ import type { KeyboardEvent } from 'react';
 import React, { useCallback, useEffect, useState } from 'react';
 
 import { useForm, Controller } from 'react-hook-form';
-import { Modal, ModalBody, ModalHeader } from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+import {
+  Modal, ModalBody, ModalFooter, ModalHeader,
+} from 'reactstrap';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { useRagSearchModal } from '~/stores/rag-search';
@@ -11,7 +14,7 @@ import loggerFactory from '~/utils/logger';
 import { MessageCard } from './MessageCard';
 import { ResizableTextarea } from './ResizableTextArea';
 
-import styles from './RagSearchModal.module.scss';
+import styles from './AiChatModal.module.scss';
 
 const moduleClass = styles['rag-search-modal'];
 
@@ -28,7 +31,9 @@ type FormData = {
   input: string;
 };
 
-const RagSearchModal = (): JSX.Element => {
+const AiChatModalSubstance = (): JSX.Element => {
+
+  const { t } = useTranslation();
 
   const form = useForm<FormData>({
     defaultValues: {
@@ -40,21 +45,9 @@ const RagSearchModal = (): JSX.Element => {
   const [messageLogs, setMessageLogs] = useState<Message[]>([]);
   const [lastMessage, setLastMessage] = useState<Message>();
 
-  const { data: ragSearchModalData, close: closeRagSearchModal } = useRagSearchModal();
-
-  const isOpened = ragSearchModalData?.isOpened ?? false;
-
-  useEffect(() => {
-    // clear states when the modal is closed
-    if (!isOpened) {
-      setMessageLogs([]);
-      setThreadId(undefined);
-    }
-  }, [isOpened]);
-
   useEffect(() => {
     // do nothing when the modal is closed or threadId is already set
-    if (!isOpened || threadId != null) {
+    if (threadId != null) {
       return;
     }
 
@@ -72,7 +65,7 @@ const RagSearchModal = (): JSX.Element => {
     };
 
     createThread();
-  }, [isOpened, threadId]);
+  }, [threadId]);
 
   const submit = useCallback(async(data: FormData) => {
     const { length: logLength } = messageLogs;
@@ -164,54 +157,81 @@ const RagSearchModal = (): JSX.Element => {
   };
 
   return (
-    <Modal size="lg" isOpen={isOpened} toggle={closeRagSearchModal} className={moduleClass}>
-      <ModalHeader tag="h4" toggle={closeRagSearchModal} className="pe-4">
-        <span className="material-symbols-outlined text-primary">psychology</span>
-        GROWI Assistant
-      </ModalHeader>
-      <ModalBody className="px-lg-5 py-4">
-        <div className="vstack gap-4">
+    <>
+      <ModalBody className="pb-0 pt-3 pt-lg-4 px-3 px-lg-4">
+        <div className="vstack gap-4 pb-4">
           { messageLogs.map(message => (
-            <MessageCard key={message.id} right={message.isUserMessage}>{message.content}</MessageCard>
+            <MessageCard key={message.id} role={message.isUserMessage ? 'user' : 'assistant'}>{message.content}</MessageCard>
           )) }
           { lastMessage != null && (
-            <MessageCard>{lastMessage.content}</MessageCard>
+            <MessageCard role="assistant">{lastMessage.content}</MessageCard>
           )}
-        </div>
-
-        <div>
-          <form onSubmit={form.handleSubmit(submit)} className="hstack gap-2 align-items-end mt-4">
-            <Controller
-              name="input"
-              control={form.control}
-              render={({ field }) => (
-                <ResizableTextarea
-                  {...field}
-                  required
-                  className="form-control textarea-ask"
-                  style={{ resize: 'none' }}
-                  rows={1}
-                  placeholder="ききたいことを入力してください"
-                  onKeyDown={keyDownHandler}
-                />
-              )}
-            />
-            <button
-              type="submit"
-              className="btn btn-submit no-border"
-              disabled={form.formState.isSubmitting}
-            >
-              <span className="material-symbols-outlined">send</span>
-            </button>
-          </form>
-
-          {form.formState.errors.input != null && (
-            <span className="text-danger small">{form.formState.errors.input?.message}</span>
+          { messageLogs.length > 0 && (
+            <div className="d-flex justify-content-center">
+              <span className="bg-body-tertiary text-body-secondary rounded-pill px-3 py-1" style={{ fontSize: 'smaller' }}>
+                {t('modal_aichat.caution_against_hallucination')}
+              </span>
+            </div>
           )}
         </div>
       </ModalBody>
-    </Modal>
+
+      <ModalFooter className="pt-0 pb-3 pb-lg-4 px-3 px-lg-4">
+        <form onSubmit={form.handleSubmit(submit)} className="flex-fill hstack gap-2 align-items-end m-0">
+          <Controller
+            name="input"
+            control={form.control}
+            render={({ field }) => (
+              <ResizableTextarea
+                {...field}
+                required
+                className="form-control textarea-ask"
+                style={{ resize: 'none' }}
+                rows={1}
+                placeholder={t('modal_aichat.placeholder')}
+                onKeyDown={keyDownHandler}
+              />
+            )}
+          />
+          <button
+            type="submit"
+            className="btn btn-submit no-border"
+            disabled={form.formState.isSubmitting}
+          >
+            <span className="material-symbols-outlined">send</span>
+          </button>
+        </form>
+
+        {form.formState.errors.input != null && (
+          <span className="text-danger small">{form.formState.errors.input?.message}</span>
+        )}
+      </ModalFooter>
+    </>
   );
 };
 
-export default RagSearchModal;
+
+export const AiChatModal = (): JSX.Element => {
+
+  const { t } = useTranslation();
+
+  const { data: ragSearchModalData, close: closeRagSearchModal } = useRagSearchModal();
+
+  const isOpened = ragSearchModalData?.isOpened ?? false;
+
+  return (
+    <Modal size="lg" isOpen={isOpened} toggle={closeRagSearchModal} className={moduleClass} scrollable>
+
+      <ModalHeader tag="h4" toggle={closeRagSearchModal} className="pe-4">
+        <span className="material-symbols-outlined growi-ai-chat-icon me-3">chat</span>
+        <span className="fw-bold">{t('modal_aichat.title')}</span>
+        <span className="fs-5 text-body-secondary ms-3">{t('modal_aichat.title_beta_label')}</span>
+      </ModalHeader>
+
+      { isOpened && (
+        <AiChatModalSubstance />
+      ) }
+
+    </Modal>
+  );
+};

+ 59 - 0
apps/app/src/features/openai/chat/components/AiChatModal/MessageCard.module.scss

@@ -0,0 +1,59 @@
+@use '@growi/core-styles/scss/bootstrap/init' as bs;
+@use '@growi/core-styles/scss/variables/growi-official-colors';
+
+// remove margin from last child
+.message-card :global {
+  .card-body {
+    p:last-child {
+      margin-bottom: 0;
+    }
+  }
+}
+
+
+/*************************
+ * AssistantMessageCard
+ ************************/
+.assistant-message-card :global {
+  .card-body {
+    --bs-card-spacer-x: 0;
+    --bs-card-spacer-y: 0.8rem;
+  }
+}
+
+ /*******************
+ * UserMessageCard
+ *******************/
+
+.user-message-card :global {
+  .card-body {
+    --bs-card-spacer-x: 1.25rem;
+    --bs-card-spacer-y: 0.8rem;
+  }
+}
+
+// baloon style
+.user-message-card :global {
+  border: 0;
+
+  --bs-card-border-radius: var(--bs-border-radius-xxl);
+  border-bottom-right-radius: var(--bs-border-radius-lg);
+}
+
+// max width
+.user-message-card :global {
+  max-width: 85%;
+  @include bs.media-breakpoint-up(lg) {
+    max-width: 75%;
+  }
+}
+
+
+
+// == Colors
+.assistant-message-card :global {
+  .grw-ai-icon {
+    color: white;
+    background-color: growi-official-colors.$growi-ai-purple;
+  }
+}

+ 47 - 0
apps/app/src/features/openai/chat/components/AiChatModal/MessageCard.tsx

@@ -0,0 +1,47 @@
+import ReactMarkdown from 'react-markdown';
+
+import styles from './MessageCard.module.scss';
+
+const moduleClass = styles['message-card'] ?? '';
+
+
+const userMessageCardModuleClass = styles['user-message-card'] ?? '';
+
+const UserMessageCard = ({ children }: { children?: string }): JSX.Element => (
+  <div className={`card d-inline-flex align-self-end bg-success-subtle bg-info-subtle ${moduleClass} ${userMessageCardModuleClass}`}>
+    { children != null && children.length > 0 && (
+      <div className="card-body">
+        <ReactMarkdown>{children}</ReactMarkdown>
+      </div>
+    ) }
+  </div>
+);
+
+
+const assistantMessageCardModuleClass = styles['assistant-message-card'] ?? '';
+
+const AssistantMessageCard = ({ children }: { children?: string }): JSX.Element => (
+  <div className={`card border-0 ${moduleClass} ${assistantMessageCardModuleClass}`}>
+    { children != null && children.length > 0 && (
+      <div className="card-body d-flex">
+        <div className="me-2 me-lg-3">
+          <span className="material-symbols-outlined grw-ai-icon rounded-pill p-1">psychology</span>
+        </div>
+        <ReactMarkdown>{children}</ReactMarkdown>
+      </div>
+    ) }
+  </div>
+);
+
+type Props = {
+  role: 'user' | 'assistant',
+  children?: string,
+}
+
+export const MessageCard = (props: Props): JSX.Element => {
+  const { role, children } = props;
+
+  return role === 'user'
+    ? <UserMessageCard>{children}</UserMessageCard>
+    : <AssistantMessageCard>{children}</AssistantMessageCard>;
+};

+ 0 - 0
apps/app/src/client/components/RagSearch/ResizableTextArea.tsx → apps/app/src/features/openai/chat/components/AiChatModal/ResizableTextArea.tsx


+ 1 - 0
apps/app/src/features/openai/chat/components/AiChatModal/index.ts

@@ -0,0 +1 @@
+export * from './AiChatModal';

+ 1 - 0
packages/core-styles/scss/variables/_growi-official-colors.scss

@@ -1,3 +1,4 @@
 // == GROWI Official Color
 $growi-green: #7AD340;
 $growi-blue: #428DD1;
+$growi-ai-purple: #a190cd;