Преглед изворни кода

Merge pull request #9648 from weseek/feat/161511-implement-chat-view

feat: Implement chat view
Yuki Takei пре 1 година
родитељ
комит
99ea41b427

+ 1 - 3
apps/app/public/static/locales/en_US/translation.json

@@ -488,9 +488,7 @@
     "latest_revision": "theirs",
     "latest_revision": "theirs",
     "selected_editable_revision": "Selected Page Body (Editable)"
     "selected_editable_revision": "Selected Page Body (Editable)"
   },
   },
-  "modal_aichat": {
-    "title": "Knowledge Assistant",
-    "title_beta_label": "(Beta)",
+  "sidebar_aichat": {
     "placeholder": "Ask me anything.",
     "placeholder": "Ask me anything.",
     "summary_mode_label": "Summary mode",
     "summary_mode_label": "Summary mode",
     "summary_mode_help": "Concise answer within 2-3 sentences",
     "summary_mode_help": "Concise answer within 2-3 sentences",

+ 1 - 3
apps/app/public/static/locales/fr_FR/translation.json

@@ -483,9 +483,7 @@
     "latest_revision": "les autres",
     "latest_revision": "les autres",
     "selected_editable_revision": "Corps de page sélectionné (Modifiable)"
     "selected_editable_revision": "Corps de page sélectionné (Modifiable)"
   },
   },
-  "modal_aichat": {
-    "title": "Assistant de Connaissance",
-    "title_beta_label": "(Bêta)",
+  "sidebar_aichat": {
     "placeholder": "Demandez-moi n'importe quoi.",
     "placeholder": "Demandez-moi n'importe quoi.",
     "summary_mode_label": "Mode résumé",
     "summary_mode_label": "Mode résumé",
     "summary_mode_help": "Réponse concise en 2-3 phrases",
     "summary_mode_help": "Réponse concise en 2-3 phrases",

+ 1 - 3
apps/app/public/static/locales/ja_JP/translation.json

@@ -521,9 +521,7 @@
     "latest_revision": "最新の本文",
     "latest_revision": "最新の本文",
     "selected_editable_revision": "保存するページ本文(編集可能)"
     "selected_editable_revision": "保存するページ本文(編集可能)"
   },
   },
-  "modal_aichat": {
-    "title": "ナレッジアシスタント",
-    "title_beta_label": "(ベータ)",
+  "sidebar_aichat": {
     "placeholder": "ききたいことを入力してください",
     "placeholder": "ききたいことを入力してください",
     "summary_mode_label": "要約モード",
     "summary_mode_label": "要約モード",
     "summary_mode_help": "2~3文以内の簡潔な回答",
     "summary_mode_help": "2~3文以内の簡潔な回答",

+ 1 - 3
apps/app/public/static/locales/zh_CN/translation.json

@@ -477,9 +477,7 @@
     "latest_revision": "最新页面正文",
     "latest_revision": "最新页面正文",
     "selected_editable_revision": "选定的可编辑页面正文"
     "selected_editable_revision": "选定的可编辑页面正文"
   },
   },
-  "modal_aichat": {
-    "title": "知识助手",
-    "title_beta_label": "(测试版)",
+  "sidebar_aichat": {
     "placeholder": "问我任何问题。",
     "placeholder": "问我任何问题。",
     "summary_mode_label": "摘要模式",
     "summary_mode_label": "摘要模式",
     "summary_mode_help": "简洁回答在2-3句话内",
     "summary_mode_help": "简洁回答在2-3句话内",

+ 2 - 2
apps/app/src/client/components/PageControls/PageControls.tsx

@@ -16,7 +16,7 @@ import {
   toggleLike, toggleSubscribe,
   toggleLike, toggleSubscribe,
 } from '~/client/services/page-operation';
 } from '~/client/services/page-operation';
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
-import RagSearchButton from '~/features/openai/client/components/RagSearchButton';
+// import RagSearchButton from '~/features/openai/client/components/RagSearchButton';
 import { useIsGuestUser, useIsReadOnlyUser, useIsSearchPage } from '~/stores-universal/context';
 import { useIsGuestUser, useIsReadOnlyUser, useIsSearchPage } from '~/stores-universal/context';
 import {
 import {
   EditorMode, useEditorMode,
   EditorMode, useEditorMode,
@@ -285,7 +285,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
       { isViewMode && isDeviceLargerThanMd && !isSearchPage && !isSearchPage && (
       { isViewMode && isDeviceLargerThanMd && !isSearchPage && !isSearchPage && (
         <>
         <>
           <SearchButton />
           <SearchButton />
-          <RagSearchButton />
+          {/* <RagSearchButton /> */}
         </>
         </>
       )}
       )}
 
 

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

@@ -40,7 +40,6 @@ const DeleteBookmarkFolderModal = dynamic(
   () => import('~/client/components/DeleteBookmarkFolderModal').then(mod => mod.DeleteBookmarkFolderModal), { ssr: false },
   () => import('~/client/components/DeleteBookmarkFolderModal').then(mod => mod.DeleteBookmarkFolderModal), { ssr: false },
 );
 );
 const SearchModal = dynamic(() => import('../../features/search/client/components/SearchModal'), { ssr: false });
 const SearchModal = dynamic(() => import('../../features/search/client/components/SearchModal'), { ssr: false });
-const AiChatModal = dynamic(() => import('~/features/openai/chat/components/AiChatModal').then(mod => mod.AiChatModal), { ssr: false });
 const AiAssistantManagementModal = dynamic(
 const AiAssistantManagementModal = dynamic(
   () => import('~/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal')
   () => import('~/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal')
     .then(mod => mod.AiAssistantManagementModal), { ssr: false },
     .then(mod => mod.AiAssistantManagementModal), { ssr: false },
@@ -81,7 +80,6 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
       <PutbackPageModal />
       <PutbackPageModal />
       <PageSelectModal />
       <PageSelectModal />
       <SearchModal />
       <SearchModal />
-      <AiChatModal />
       <AiAssistantManagementModal />
       <AiAssistantManagementModal />
 
 
       <PagePresentationModal />
       <PagePresentationModal />

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

@@ -1,27 +0,0 @@
-@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';
-
-.grw-aichat-modal :global {
-
-  .textarea-ask {
-    max-height: 30vh;
-  }
-
-  .btn-submit {
-    font-size: 1.1em;
-  }
-}
-
-
-// == Colors
-.grw-aichat-modal :global {
-  .growi-ai-chat-icon {
-    color: growi-official-colors.$growi-ai-purple;
-  }
-
-  .btn-submit {
-    @include btn-muted.colorize(bs.$purple, bs.$purple);
-  }
-}
-

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

@@ -1,369 +0,0 @@
-import type { KeyboardEvent } from 'react';
-import React, { useCallback, useState } from 'react';
-
-import { useForm, Controller } from 'react-hook-form';
-import { useTranslation } from 'react-i18next';
-import {
-  Collapse,
-  Modal, ModalBody, ModalFooter, ModalHeader,
-  UncontrolledTooltip, Input,
-} from 'reactstrap';
-
-import { apiv3Post } from '~/client/util/apiv3-client';
-import { toastError } from '~/client/util/toastr';
-import { useGrowiCloudUri } from '~/stores-universal/context';
-import loggerFactory from '~/utils/logger';
-
-import { SelectedPageList } from '../../../client/components/Common/SelectedPageList';
-import { useRagSearchModal } from '../../../client/stores/rag-search';
-import { MessageErrorCode, StreamErrorCode } from '../../../interfaces/message-error';
-
-import { MessageCard } from './MessageCard';
-import { ResizableTextarea } from './ResizableTextArea';
-
-import styles from './AiChatModal.module.scss';
-
-const moduleClass = styles['grw-aichat-modal'] ?? '';
-
-const logger = loggerFactory('growi:clinet:components:RagSearchModal');
-
-
-type Message = {
-  id: string,
-  content: string,
-  isUserMessage?: boolean,
-}
-
-type FormData = {
-  input: string;
-  summaryMode?: boolean;
-};
-
-const AiChatModalSubstance = (): JSX.Element => {
-
-  const { t } = useTranslation();
-
-  const form = useForm<FormData>({
-    defaultValues: {
-      input: '',
-      summaryMode: true,
-    },
-  });
-
-  const [threadId, setThreadId] = useState<string | undefined>();
-  const [messageLogs, setMessageLogs] = useState<Message[]>([]);
-  const [generatingAnswerMessage, setGeneratingAnswerMessage] = useState<Message>();
-  const [errorMessage, setErrorMessage] = useState<string | undefined>();
-  const [isErrorDetailCollapsed, setIsErrorDetailCollapsed] = useState<boolean>(false);
-
-  const { data: growiCloudUri } = useGrowiCloudUri();
-
-  const isGenerating = generatingAnswerMessage != null;
-
-  const submit = useCallback(async(data: FormData) => {
-    // do nothing when the assistant is generating an answer
-    if (isGenerating) {
-      return;
-    }
-
-    // do nothing when the input is empty
-    if (data.input.trim().length === 0) {
-      return;
-    }
-
-    const { length: logLength } = messageLogs;
-
-    // add user message to the logs
-    const newUserMessage = { id: logLength.toString(), content: data.input, isUserMessage: true };
-    setMessageLogs(msgs => [...msgs, newUserMessage]);
-
-    // reset form
-    form.reset({ input: '', summaryMode: data.summaryMode });
-    setErrorMessage(undefined);
-
-    // add an empty assistant message
-    const newAnswerMessage = { id: (logLength + 1).toString(), content: '' };
-    setGeneratingAnswerMessage(newAnswerMessage);
-
-    // create thread
-    let currentThreadId = threadId;
-    if (threadId == null) {
-      try {
-        const res = await apiv3Post('/openai/thread');
-        const thread = res.data.thread;
-
-        setThreadId(thread.id);
-        currentThreadId = thread.id;
-      }
-      catch (err) {
-        logger.error(err.toString());
-        toastError(t('modal_aichat.failed_to_create_or_retrieve_thread'));
-      }
-    }
-
-    // post message
-    try {
-      const response = await fetch('/_api/v3/openai/message', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ userMessage: data.input, threadId: currentThreadId, summaryMode: data.summaryMode }),
-      });
-
-      if (!response.ok) {
-        const resJson = await response.json();
-        if ('errors' in resJson) {
-          // eslint-disable-next-line @typescript-eslint/no-unused-vars
-          const errors = resJson.errors.map(({ message }) => message).join(', ');
-          form.setError('input', { type: 'manual', message: `[${response.status}] ${errors}` });
-
-          const hasThreadIdNotSetError = resJson.errors.some(err => err.code === MessageErrorCode.THREAD_ID_IS_NOT_SET);
-          if (hasThreadIdNotSetError) {
-            toastError(t('modal_aichat.failed_to_create_or_retrieve_thread'));
-          }
-        }
-        setGeneratingAnswerMessage(undefined);
-        return;
-      }
-
-      const reader = response.body?.getReader();
-      const decoder = new TextDecoder('utf-8');
-
-      const read = async() => {
-        if (reader == null) return;
-
-        const { done, value } = await reader.read();
-
-        // add assistant message to the logs
-        if (done) {
-          setGeneratingAnswerMessage((generatingAnswerMessage) => {
-            if (generatingAnswerMessage == null) return;
-            setMessageLogs(msgs => [...msgs, generatingAnswerMessage]);
-            return undefined;
-          });
-          return;
-        }
-
-        const chunk = decoder.decode(value);
-
-        const textValues: string[] = [];
-        const lines = chunk.split('\n\n');
-        lines.forEach((line) => {
-          const trimedLine = line.trim();
-          if (trimedLine.startsWith('data:')) {
-            const data = JSON.parse(line.replace('data: ', ''));
-            textValues.push(data.content[0].text.value);
-          }
-          else if (trimedLine.startsWith('error:')) {
-            const error = JSON.parse(line.replace('error: ', ''));
-            logger.error(error.errorMessage);
-            form.setError('input', { type: 'manual', message: error.message });
-
-            if (error.code === StreamErrorCode.BUDGET_EXCEEDED) {
-              setErrorMessage(growiCloudUri != null ? 'modal_aichat.budget_exceeded_for_growi_cloud' : 'modal_aichat.budget_exceeded');
-            }
-          }
-        });
-
-
-        // append text values to the assistant message
-        setGeneratingAnswerMessage((prevMessage) => {
-          if (prevMessage == null) return;
-          return {
-            ...prevMessage,
-            content: prevMessage.content + textValues.join(''),
-          };
-        });
-
-        read();
-      };
-      read();
-    }
-    catch (err) {
-      logger.error(err.toString());
-      form.setError('input', { type: 'manual', message: err.toString() });
-    }
-
-  }, [form, growiCloudUri, isGenerating, messageLogs, t, threadId]);
-
-  const keyDownHandler = (event: KeyboardEvent<HTMLTextAreaElement>) => {
-    if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
-      form.handleSubmit(submit)();
-    }
-  };
-
-  return (
-    <>
-      <ModalBody className="pb-0 pt-3 pt-lg-4 px-3 px-lg-4">
-        <div className="d-flex mb-4">
-          <Input type="select" className="border rounded">
-            <option>
-              GROWI AI の機能について
-            </option>
-          </Input>
-
-          <button type="button" className="btn btn-outline-secondary bg-transparent ms-2">
-            <span className="fs-5 material-symbols-outlined">edit</span>
-          </button>
-
-          <button type="button" className="btn btn-outline-secondary bg-transparent ms-2">
-            <span className="fs-5 material-symbols-outlined">add</span>
-          </button>
-        </div>
-
-        <div className="text-muted mb-4">
-          ここに設定したアシスタントの説明が入ります。ここに設定したアシスタントの説明が入ります。
-        </div>
-
-        <div className="mb-4">
-          <p className="mb-2">アシスタントへの指示</p>
-          <div className="p-3 alert alert-primary">
-            <p className="mb-0 text-break">
-              あなたは生成AIの専門家および、リサーチャーです。ナレッジベースのWikiツールである GROWIのAI機能に関する情報を提示したり、使われている技術に関する説明をしたりします。
-            </p>
-          </div>
-        </div>
-
-        <div className="d-flex align-items-center mb-2">
-          <p className="mb-0">参照するページ</p>
-          <span className="ms-1 fs-5 material-symbols-outlined text-secondary">help</span>
-        </div>
-        <SelectedPageList selectedPages={[
-          { page: { _id: '1', path: '/Project/GROWI/新機能/GROWI AI' }, isIncludeSubPage: true },
-          { page: { _id: '2', path: '/AI導入検討/調査' }, isIncludeSubPage: false },
-        ]}
-        />
-
-        <div className="vstack gap-4 pb-2">
-          { messageLogs.map(message => (
-            <MessageCard key={message.id} role={message.isUserMessage ? 'user' : 'assistant'}>{message.content}</MessageCard>
-          )) }
-          { generatingAnswerMessage != null && (
-            <MessageCard role="assistant">{generatingAnswerMessage.content}</MessageCard>
-          )}
-          { 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>
-
-      <ModalFooter className="flex-column align-items-start pt-0 pb-3 pb-lg-4 px-3 px-lg-4">
-        <form onSubmit={form.handleSubmit(submit)} className="flex-fill vstack gap-3">
-          <div 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={!form.formState.isSubmitting ? t('modal_aichat.placeholder') : ''}
-                  onKeyDown={keyDownHandler}
-                  disabled={form.formState.isSubmitting}
-                />
-              )}
-            />
-            <button
-              type="submit"
-              className="btn btn-submit no-border"
-              disabled={form.formState.isSubmitting || isGenerating}
-            >
-              <span className="material-symbols-outlined">send</span>
-            </button>
-          </div>
-          <div className="form-check form-switch">
-            <input
-              id="swSummaryMode"
-              type="checkbox"
-              role="switch"
-              className="form-check-input"
-              {...form.register('summaryMode')}
-              disabled={form.formState.isSubmitting || isGenerating}
-            />
-            <label className="form-check-label" htmlFor="swSummaryMode">
-              {t('modal_aichat.summary_mode_label')}
-            </label>
-
-            {/* Help */}
-            <a
-              id="tooltipForHelpOfSummaryMode"
-              role="button"
-              className="ms-1"
-            >
-              <span className="material-symbols-outlined fs-6" style={{ lineHeight: 'unset' }}>help</span>
-            </a>
-            <UncontrolledTooltip
-              target="tooltipForHelpOfSummaryMode"
-            >
-              {t('modal_aichat.summary_mode_help')}
-            </UncontrolledTooltip>
-          </div>
-        </form>
-
-
-        {form.formState.errors.input != null && (
-          <div className="mt-4 bg-danger bg-opacity-10 rounded-3 p-2 w-100">
-            <div>
-              <span className="material-symbols-outlined text-danger me-2">error</span>
-              <span className="text-danger">{ errorMessage != null ? t(errorMessage) : t('modal_aichat.error_message') }</span>
-            </div>
-
-            <button
-              type="button"
-              className="btn btn-link text-secondary p-0"
-              aria-expanded={isErrorDetailCollapsed}
-              onClick={() => setIsErrorDetailCollapsed(!isErrorDetailCollapsed)}
-            >
-              <span className={`material-symbols-outlined mt-2 me-1 ${isErrorDetailCollapsed ? 'rotate-90' : ''}`}>
-                chevron_right
-              </span>
-              <span className="small">{t('modal_aichat.show_error_detail')}</span>
-            </button>
-
-            <Collapse isOpen={isErrorDetailCollapsed}>
-              <div className="ms-2">
-                <div className="">
-                  <div className="text-secondary small">
-                    {form.formState.errors.input?.message}
-                  </div>
-                </div>
-              </div>
-            </Collapse>
-          </div>
-        )}
-      </ModalFooter>
-    </>
-  );
-};
-
-
-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="growi-custom-icons growi-ai-chat-icon me-3 fs-4">ai_assistant</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>
-  );
-};

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

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

+ 11 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar.module.scss

@@ -2,6 +2,17 @@
 @use '@growi/core-styles/scss/variables/growi-official-colors';
 @use '@growi/core-styles/scss/variables/growi-official-colors';
 @use '@growi/ui/scss/atoms/btn-muted';
 @use '@growi/ui/scss/atoms/btn-muted';
 
 
+.grw-ai-assistant-chat-sidebar :global {
+
+  .textarea-ask {
+    max-height: 30vh;
+  }
+
+  .btn-submit {
+    font-size: 1.1em;
+  }
+}
+
 // == Colors
 // == Colors
 .grw-ai-assistant-chat-sidebar :global {
 .grw-ai-assistant-chat-sidebar :global {
   .growi-ai-chat-icon {
   .growi-ai-chat-icon {

+ 338 - 15
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar.tsx

@@ -1,43 +1,366 @@
+import type { KeyboardEvent } from 'react';
 import {
 import {
-  type FC, memo, useRef, useEffect,
+  type FC, memo, useRef, useEffect, useState, useCallback,
 } from 'react';
 } from 'react';
 
 
+import { useForm, Controller } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+import { Collapse, UncontrolledTooltip } from 'reactstrap';
 import SimpleBar from 'simplebar-react';
 import SimpleBar from 'simplebar-react';
 
 
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { toastError } from '~/client/util/toastr';
+import { MessageErrorCode, StreamErrorCode } from '~/features/openai/interfaces/message-error';
+import { useGrowiCloudUri } from '~/stores-universal/context';
+import loggerFactory from '~/utils/logger';
+
 import type { AiAssistantHasId } from '../../../../interfaces/ai-assistant';
 import type { AiAssistantHasId } from '../../../../interfaces/ai-assistant';
 import { useAiAssistantChatSidebar } from '../../../stores/ai-assistant';
 import { useAiAssistantChatSidebar } from '../../../stores/ai-assistant';
 
 
+import { MessageCard } from './MessageCard';
+import { ResizableTextarea } from './ResizableTextArea';
+
 import styles from './AiAssistantChatSidebar.module.scss';
 import styles from './AiAssistantChatSidebar.module.scss';
 
 
+const logger = loggerFactory('growi:openai:client:components:AiAssistantChatSidebar');
+
 const moduleClass = styles['grw-ai-assistant-chat-sidebar'] ?? '';
 const moduleClass = styles['grw-ai-assistant-chat-sidebar'] ?? '';
 
 
 const RIGHT_SIDEBAR_WIDTH = 500;
 const RIGHT_SIDEBAR_WIDTH = 500;
 
 
+type Message = {
+  id: string,
+  content: string,
+  isUserMessage?: boolean,
+}
+
+type FormData = {
+  input: string;
+  summaryMode?: boolean;
+};
+
+
 type AiAssistantChatSidebarSubstanceProps = {
 type AiAssistantChatSidebarSubstanceProps = {
   aiAssistantData?: AiAssistantHasId;
   aiAssistantData?: AiAssistantHasId;
   closeAiAssistantChatSidebar: () => void
   closeAiAssistantChatSidebar: () => void
 }
 }
 
 
 const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceProps> = (props: AiAssistantChatSidebarSubstanceProps) => {
 const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceProps> = (props: AiAssistantChatSidebarSubstanceProps) => {
+  const { t } = useTranslation();
+
   const { aiAssistantData, closeAiAssistantChatSidebar } = props;
   const { aiAssistantData, closeAiAssistantChatSidebar } = props;
 
 
+  const form = useForm<FormData>({
+    defaultValues: {
+      input: '',
+      summaryMode: true,
+    },
+  });
+
+  const [threadId, setThreadId] = useState<string | undefined>();
+  const [messageLogs, setMessageLogs] = useState<Message[]>([]);
+  const [generatingAnswerMessage, setGeneratingAnswerMessage] = useState<Message>();
+  const [errorMessage, setErrorMessage] = useState<string | undefined>();
+  const [isErrorDetailCollapsed, setIsErrorDetailCollapsed] = useState<boolean>(false);
+
+  const { data: growiCloudUri } = useGrowiCloudUri();
+
+  const isGenerating = generatingAnswerMessage != null;
+
+  const submit = useCallback(async(data: FormData) => {
+    // do nothing when the assistant is generating an answer
+    if (isGenerating) {
+      return;
+    }
+
+    // do nothing when the input is empty
+    if (data.input.trim().length === 0) {
+      return;
+    }
+
+    const { length: logLength } = messageLogs;
+
+    // add user message to the logs
+    const newUserMessage = { id: logLength.toString(), content: data.input, isUserMessage: true };
+    setMessageLogs(msgs => [...msgs, newUserMessage]);
+
+    // reset form
+    form.reset({ input: '', summaryMode: data.summaryMode });
+    setErrorMessage(undefined);
+
+    // add an empty assistant message
+    const newAnswerMessage = { id: (logLength + 1).toString(), content: '' };
+    setGeneratingAnswerMessage(newAnswerMessage);
+
+    // create thread
+    let currentThreadId = threadId;
+    if (threadId == null) {
+      try {
+        const res = await apiv3Post('/openai/thread');
+        const thread = res.data.thread;
+
+        setThreadId(thread.id);
+        currentThreadId = thread.id;
+      }
+      catch (err) {
+        logger.error(err.toString());
+        toastError(t('sidebar_aichat.failed_to_create_or_retrieve_thread'));
+      }
+    }
+
+    // post message
+    try {
+      const response = await fetch('/_api/v3/openai/message', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ userMessage: data.input, threadId: currentThreadId, summaryMode: data.summaryMode }),
+      });
+
+      if (!response.ok) {
+        const resJson = await response.json();
+        if ('errors' in resJson) {
+          // eslint-disable-next-line @typescript-eslint/no-unused-vars
+          const errors = resJson.errors.map(({ message }) => message).join(', ');
+          form.setError('input', { type: 'manual', message: `[${response.status}] ${errors}` });
+
+          const hasThreadIdNotSetError = resJson.errors.some(err => err.code === MessageErrorCode.THREAD_ID_IS_NOT_SET);
+          if (hasThreadIdNotSetError) {
+            toastError(t('sidebar_aichat.failed_to_create_or_retrieve_thread'));
+          }
+        }
+        setGeneratingAnswerMessage(undefined);
+        return;
+      }
+
+      const reader = response.body?.getReader();
+      const decoder = new TextDecoder('utf-8');
+
+      const read = async() => {
+        if (reader == null) return;
+
+        const { done, value } = await reader.read();
+
+        // add assistant message to the logs
+        if (done) {
+          setGeneratingAnswerMessage((generatingAnswerMessage) => {
+            if (generatingAnswerMessage == null) return;
+            setMessageLogs(msgs => [...msgs, generatingAnswerMessage]);
+            return undefined;
+          });
+          return;
+        }
+
+        const chunk = decoder.decode(value);
+
+        const textValues: string[] = [];
+        const lines = chunk.split('\n\n');
+        lines.forEach((line) => {
+          const trimedLine = line.trim();
+          if (trimedLine.startsWith('data:')) {
+            const data = JSON.parse(line.replace('data: ', ''));
+            textValues.push(data.content[0].text.value);
+          }
+          else if (trimedLine.startsWith('error:')) {
+            const error = JSON.parse(line.replace('error: ', ''));
+            logger.error(error.errorMessage);
+            form.setError('input', { type: 'manual', message: error.message });
+
+            if (error.code === StreamErrorCode.BUDGET_EXCEEDED) {
+              setErrorMessage(growiCloudUri != null ? 'sidebar_aichat.budget_exceeded_for_growi_cloud' : 'sidebar_aichat.budget_exceeded');
+            }
+          }
+        });
+
+
+        // append text values to the assistant message
+        setGeneratingAnswerMessage((prevMessage) => {
+          if (prevMessage == null) return;
+          return {
+            ...prevMessage,
+            content: prevMessage.content + textValues.join(''),
+          };
+        });
+
+        read();
+      };
+      read();
+    }
+    catch (err) {
+      logger.error(err.toString());
+      form.setError('input', { type: 'manual', message: err.toString() });
+    }
+
+  }, [form, growiCloudUri, isGenerating, messageLogs, t, threadId]);
+
+  const keyDownHandler = (event: KeyboardEvent<HTMLTextAreaElement>) => {
+    if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
+      form.handleSubmit(submit)();
+    }
+  };
+
+
   return (
   return (
     <>
     <>
-      <div className="d-flex align-items-center p-3 border-bottom">
-        <span className="growi-custom-icons growi-ai-chat-icon me-3 fs-4">ai_assistant</span>
-        <h5 className="mb-0 fw-bold flex-grow-1 text-truncate">{aiAssistantData?.name}</h5>
-        <button
-          type="button"
-          className="btn btn-link p-0 border-0"
-          onClick={closeAiAssistantChatSidebar}
-        >
-          <span className="material-symbols-outlined">close</span>
-        </button>
-      </div>
+      <div className="d-flex flex-column vh-100">
+        <div className="d-flex align-items-center p-3 border-bottom">
+          <span className="growi-custom-icons growi-ai-chat-icon me-3 fs-4">ai_assistant</span>
+          <h5 className="mb-0 fw-bold flex-grow-1 text-truncate">{aiAssistantData?.name}</h5>
+          <button
+            type="button"
+            className="btn btn-link p-0 border-0"
+            onClick={closeAiAssistantChatSidebar}
+          >
+            <span className="material-symbols-outlined">close</span>
+          </button>
+        </div>
+        <div className="p-4 d-flex flex-column gap-4 vh-100">
+
+
+          { threadId != null
+            ? (
+              <div className="vstack gap-4 pb-2">
+                { messageLogs.map(message => (
+                  <MessageCard key={message.id} role={message.isUserMessage ? 'user' : 'assistant'}>{message.content}</MessageCard>
+                )) }
+                { generatingAnswerMessage != null && (
+                  <MessageCard role="assistant">{generatingAnswerMessage.content}</MessageCard>
+                )}
+                { 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('sidebar_aichat.caution_against_hallucination')}
+                    </span>
+                  </div>
+                )}
+              </div>
+            )
+            : (
+              <>
+                <p className="fs-6 text-secondary mb-0">
+                  {aiAssistantData?.description}
+                </p>
+
+                <div>
+                  <p className="text-secondary">アシスタントへの指示</p>
+                  <div className="card bg-light border-0">
+                    <div className="card-body p-3">
+                      <p className="fs-6 text-secondary mb-0">
+                        {aiAssistantData?.additionalInstruction}
+                      </p>
+                    </div>
+                  </div>
+                </div>
+
+                <div>
+                  <div className="d-flex align-items-center">
+                    <p className="text-secondary mb-0">参照するページ</p>
+                  </div>
+                  <div className="d-flex flex-column gap-1">
+                    { aiAssistantData?.pagePathPatterns.map(pagePathPattern => (
+                      <a
+                        key={pagePathPattern}
+                        href="#"
+                        className="fs-6 text-secondary text-decoration-none"
+                      >
+                        {pagePathPattern}
+                      </a>
+                    ))}
+                  </div>
+                </div>
+
+              </>
+            )
+          }
 
 
-      <div className="p-3 w-100">
-        {/* AI Chat Screen Implementation */}
-        {/* TODO: https://redmine.weseek.co.jp/issues/161511 */}
+          <div className="mt-auto">
+            <form onSubmit={form.handleSubmit(submit)} className="flex-fill vstack gap-3">
+              <div 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={!form.formState.isSubmitting ? t('sidebar_aichat.placeholder') : ''}
+                      onKeyDown={keyDownHandler}
+                      disabled={form.formState.isSubmitting}
+                    />
+                  )}
+                />
+                <button
+                  type="submit"
+                  className="btn btn-submit no-border"
+                  disabled={form.formState.isSubmitting || isGenerating}
+                >
+                  <span className="material-symbols-outlined">send</span>
+                </button>
+              </div>
+              <div className="form-check form-switch">
+                <input
+                  id="swSummaryMode"
+                  type="checkbox"
+                  role="switch"
+                  className="form-check-input"
+                  {...form.register('summaryMode')}
+                  disabled={form.formState.isSubmitting || isGenerating}
+                />
+                <label className="form-check-label" htmlFor="swSummaryMode">
+                  {t('sidebar_aichat.summary_mode_label')}
+                </label>
+
+                {/* Help */}
+                <a
+                  id="tooltipForHelpOfSummaryMode"
+                  role="button"
+                  className="ms-1"
+                >
+                  <span className="material-symbols-outlined fs-6" style={{ lineHeight: 'unset' }}>help</span>
+                </a>
+                <UncontrolledTooltip
+                  target="tooltipForHelpOfSummaryMode"
+                >
+                  {t('sidebar_aichat.summary_mode_help')}
+                </UncontrolledTooltip>
+              </div>
+            </form>
+
+            {form.formState.errors.input != null && (
+              <div className="mt-4 bg-danger bg-opacity-10 rounded-3 p-2 w-100">
+                <div>
+                  <span className="material-symbols-outlined text-danger me-2">error</span>
+                  <span className="text-danger">{ errorMessage != null ? t(errorMessage) : t('sidebar_aichat.error_message') }</span>
+                </div>
+
+                <button
+                  type="button"
+                  className="btn btn-link text-secondary p-0"
+                  aria-expanded={isErrorDetailCollapsed}
+                  onClick={() => setIsErrorDetailCollapsed(!isErrorDetailCollapsed)}
+                >
+                  <span className={`material-symbols-outlined mt-2 me-1 ${isErrorDetailCollapsed ? 'rotate-90' : ''}`}>
+                    chevron_right
+                  </span>
+                  <span className="small">{t('sidebar_aichat.show_error_detail')}</span>
+                </button>
+
+                <Collapse isOpen={isErrorDetailCollapsed}>
+                  <div className="ms-2">
+                    <div className="">
+                      <div className="text-secondary small">
+                        {form.formState.errors.input?.message}
+                      </div>
+                    </div>
+                  </div>
+                </Collapse>
+              </div>
+            )}
+
+          </div>
+        </div>
       </div>
       </div>
     </>
     </>
   );
   );

+ 0 - 0
apps/app/src/features/openai/chat/components/AiChatModal/MessageCard.module.scss → apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/MessageCard.module.scss


+ 5 - 5
apps/app/src/features/openai/chat/components/AiChatModal/MessageCard.tsx → apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/MessageCard.tsx

@@ -6,7 +6,7 @@ import ReactMarkdown from 'react-markdown';
 
 
 import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
 import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
 
 
-import { useRagSearchModal } from '../../../client/stores/rag-search';
+import { useAiAssistantChatSidebar } from '../../../stores/ai-assistant';
 
 
 import styles from './MessageCard.module.scss';
 import styles from './MessageCard.module.scss';
 
 
@@ -27,11 +27,11 @@ const UserMessageCard = ({ children }: { children: string }): JSX.Element => (
 const assistantMessageCardModuleClass = styles['assistant-message-card'] ?? '';
 const assistantMessageCardModuleClass = styles['assistant-message-card'] ?? '';
 
 
 const NextLinkWrapper = (props: LinkProps & {children: string, href: string}): JSX.Element => {
 const NextLinkWrapper = (props: LinkProps & {children: string, href: string}): JSX.Element => {
-  const { close: closeRagSearchModal } = useRagSearchModal();
+  const { close: closeAiAssistantChatSidebar } = useAiAssistantChatSidebar();
 
 
   const onClick = useCallback(() => {
   const onClick = useCallback(() => {
-    closeRagSearchModal();
-  }, [closeRagSearchModal]);
+    closeAiAssistantChatSidebar();
+  }, [closeAiAssistantChatSidebar]);
 
 
   return (
   return (
     <NextLink href={props.href} onClick={onClick} className="link-primary">
     <NextLink href={props.href} onClick={onClick} className="link-primary">
@@ -55,7 +55,7 @@ const AssistantMessageCard = ({ children }: { children: string }): JSX.Element =
             )
             )
             : (
             : (
               <span className="text-thinking">
               <span className="text-thinking">
-                {t('modal_aichat.progress_label')} <span className="material-symbols-outlined">more_horiz</span>
+                {t('sidebar_aichat.progress_label')} <span className="material-symbols-outlined">more_horiz</span>
               </span>
               </span>
             )
             )
           }
           }

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