Browse Source

Merge branch 'feat/openai-vector-searching' into feat/154701-delete-uploaded-files-if-create-vector-store-file-batch-fails

Shun Miyazawa 1 year ago
parent
commit
e55ce0ef6e
32 changed files with 1023 additions and 153 deletions
  1. 1 0
      .changeset/config.json
  2. 5 0
      .changeset/odd-ladybugs-unite.md
  3. 1 0
      apps/app/next.config.js
  4. 1 0
      apps/app/package.json
  5. 6 0
      apps/app/public/static/locales/en_US/translation.json
  6. 6 0
      apps/app/public/static/locales/fr_FR/translation.json
  7. 6 0
      apps/app/public/static/locales/ja_JP/translation.json
  8. 6 0
      apps/app/public/static/locales/zh_CN/translation.json
  9. 0 23
      apps/app/src/client/components/RagSearch/MessageCard.tsx
  10. 0 97
      apps/app/src/client/components/RagSearch/RagSearchModal.tsx
  11. 2 2
      apps/app/src/components/Layout/BasicLayout.tsx
  12. 26 0
      apps/app/src/features/openai/chat/components/AiChatModal/AiChatModal.module.scss
  13. 237 0
      apps/app/src/features/openai/chat/components/AiChatModal/AiChatModal.tsx
  14. 59 0
      apps/app/src/features/openai/chat/components/AiChatModal/MessageCard.module.scss
  15. 47 0
      apps/app/src/features/openai/chat/components/AiChatModal/MessageCard.tsx
  16. 22 0
      apps/app/src/features/openai/chat/components/AiChatModal/ResizableTextArea.tsx
  17. 1 0
      apps/app/src/features/openai/chat/components/AiChatModal/index.ts
  18. 8 2
      apps/app/src/server/routes/apiv3/openai/index.ts
  19. 85 0
      apps/app/src/server/routes/apiv3/openai/message.ts
  20. 10 29
      apps/app/src/server/routes/apiv3/openai/thread.ts
  21. 1 0
      packages/core-styles/scss/variables/_growi-official-colors.scss
  22. 2 0
      packages/markdown-splitter/.eslintignore
  23. 5 0
      packages/markdown-splitter/.eslintrc.cjs
  24. 1 0
      packages/markdown-splitter/.gitignore
  25. 43 0
      packages/markdown-splitter/package.json
  26. 1 0
      packages/markdown-splitter/src/index.ts
  27. 106 0
      packages/markdown-splitter/src/services/markdown-splitter.ts
  28. 252 0
      packages/markdown-splitter/test/index.spec.ts
  29. 16 0
      packages/markdown-splitter/tsconfig.json
  30. 39 0
      packages/markdown-splitter/vite.config.ts
  31. 25 0
      packages/markdown-splitter/vitest.config.ts
  32. 3 0
      yarn.lock

+ 1 - 0
.changeset/config.json

@@ -15,6 +15,7 @@
     "@growi/app",
     "@growi/slackbot-proxy",
     "@growi/custom-icons",
+    "@growi/markdown-splitter",
     "@growi/editor",
     "@growi/presentation",
     "@growi/preset-*",

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

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

+ 1 - 0
apps/app/next.config.js

@@ -73,6 +73,7 @@ const getTranspilePackages = () => {
 const optimizePackageImports = [
   '@growi/core',
   '@growi/editor',
+  '@growi/markdown-splitter',
   '@growi/pluginkit',
   '@growi/presentation',
   '@growi/preset-themes',

+ 1 - 0
apps/app/package.json

@@ -222,6 +222,7 @@
     "@growi/core-styles": "link:../../packages/core-styles",
     "@growi/custom-icons": "link:../../packages/custom-icons",
     "@growi/editor": "link:../../packages/editor",
+    "@growi/markdown-splitter": "link:../../packages/markdown-splitter",
     "@growi/ui": "link:../../packages/ui",
     "@handsontable/react": "=2.1.0",
     "@next/bundle-analyzer": "^14.1.3",

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

+ 0 - 97
apps/app/src/client/components/RagSearch/RagSearchModal.tsx

@@ -1,97 +0,0 @@
-import React, { useState } from 'react';
-
-import { Modal, ModalBody, ModalHeader } from 'reactstrap';
-
-import { apiv3Post } from '~/client/util/apiv3-client';
-import { useRagSearchModal } from '~/stores/rag-search';
-import loggerFactory from '~/utils/logger';
-
-import { MessageCard } from './MessageCard';
-
-
-const logger = loggerFactory('growi:clinet:components:RagSearchModal');
-
-
-type Message = {
-  id: string,
-  content: string,
-  isUserMessage?: boolean,
-}
-
-const RagSearchModal = (): JSX.Element => {
-
-  const [input, setInput] = useState('');
-
-  const [threadId, setThreadId] = useState<string | undefined>();
-  const [messages, setMessages] = useState<Message[]>([]);
-
-  const { data: ragSearchModalData, close: closeRagSearchModal } = useRagSearchModal();
-
-  const onClickSubmitUserMessageHandler = async() => {
-    const newUserMessage = { id: messages.length.toString(), content: input, isUserMessage: true };
-    setMessages(msgs => [...msgs, newUserMessage]);
-
-    setInput('');
-
-    try {
-      const res = await apiv3Post('/openai/chat', { userMessage: input, threadId });
-      const assistantMessageData = res.data.messages;
-
-      if (assistantMessageData.data.length > 0) {
-        const newMessages: Message[] = assistantMessageData.data.reverse()
-          .map((message: any) => {
-            return {
-              id: message.id,
-              content: message.content[0].text.value,
-            };
-          });
-
-        setMessages(msgs => [...msgs, ...newMessages]);
-        setThreadId(assistantMessageData.data[0].threadId);
-      }
-
-    }
-    catch (err) {
-      logger.error(err.toString());
-    }
-  };
-
-  return (
-    <Modal size="lg" isOpen={ragSearchModalData?.isOpened ?? false} toggle={closeRagSearchModal} data-testid="search-modal">
-      <ModalBody>
-        <ModalHeader tag="h4" className="mb-3 p-0">
-          <span className="material-symbols-outlined me-2 text-primary">psychology</span>
-          GROWI Assistant
-        </ModalHeader>
-
-        <div className="vstack gap-4">
-          { messages.map(message => (
-            <MessageCard key={message.id} right={message.isUserMessage}>{message.content}</MessageCard>
-          )) }
-        </div>
-
-        <div className="input-group mt-5">
-          <input
-            type="text"
-            className="form-control"
-            placeholder="お手伝いできることはありますか?"
-            aria-label="Recipient's username"
-            aria-describedby="button-addon2"
-            value={input}
-            onChange={e => setInput(e.target.value)}
-          />
-          <button
-            type="button"
-            id="button-addon2"
-            className="btn btn-outline-secondary"
-            onClick={onClickSubmitUserMessageHandler}
-          >
-            <span className="material-symbols-outlined">arrow_upward</span>
-          </button>
-        </div>
-      </ModalBody>
-    </Modal>
-  );
-};
-
-export default RagSearchModal;

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

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

@@ -0,0 +1,26 @@
+@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 {
+
+  .textarea-ask {
+    max-height: 30vh;
+  }
+
+  .btn-submit {
+    font-size: 1.1em;
+  }
+}
+
+
+// == 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);
+  }
+}

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

@@ -0,0 +1,237 @@
+import type { KeyboardEvent } from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
+
+import { useForm, Controller } from 'react-hook-form';
+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';
+import loggerFactory from '~/utils/logger';
+
+import { MessageCard } from './MessageCard';
+import { ResizableTextarea } from './ResizableTextArea';
+
+import styles from './AiChatModal.module.scss';
+
+const moduleClass = styles['rag-search-modal'];
+
+const logger = loggerFactory('growi:clinet:components:RagSearchModal');
+
+
+type Message = {
+  id: string,
+  content: string,
+  isUserMessage?: boolean,
+}
+
+type FormData = {
+  input: string;
+};
+
+const AiChatModalSubstance = (): JSX.Element => {
+
+  const { t } = useTranslation();
+
+  const form = useForm<FormData>({
+    defaultValues: {
+      input: '',
+    },
+  });
+
+  const [threadId, setThreadId] = useState<string | undefined>();
+  const [messageLogs, setMessageLogs] = useState<Message[]>([]);
+  const [lastMessage, setLastMessage] = useState<Message>();
+
+  useEffect(() => {
+    // do nothing when the modal is closed or threadId is already set
+    if (threadId != null) {
+      return;
+    }
+
+    const createThread = async() => {
+      // create thread
+      try {
+        const res = await apiv3Post('/openai/thread', { threadId });
+        const thread = res.data.thread;
+
+        setThreadId(thread.id);
+      }
+      catch (err) {
+        logger.error(err.toString());
+      }
+    };
+
+    createThread();
+  }, [threadId]);
+
+  const submit = useCallback(async(data: FormData) => {
+    const { length: logLength } = messageLogs;
+
+    // post message
+    try {
+      form.clearErrors();
+
+      const response = await fetch('/_api/v3/openai/message', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ userMessage: data.input, threadId }),
+      });
+
+      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}` });
+        }
+        return;
+      }
+
+      // add user message to the logs
+      const newUserMessage = { id: logLength.toString(), content: data.input, isUserMessage: true };
+      setMessageLogs(msgs => [...msgs, newUserMessage]);
+
+      // reset form
+      form.reset();
+
+      // add assistant message
+      const newAssistantMessage = { id: (logLength + 1).toString(), content: '' };
+      setLastMessage(newAssistantMessage);
+
+      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) {
+          setLastMessage((lastMessage) => {
+            if (lastMessage == null) return;
+            setMessageLogs(msgs => [...msgs, lastMessage]);
+            return undefined;
+          });
+          return;
+        }
+
+        const chunk = decoder.decode(value);
+
+        // Extract text values from the chunk
+        const textValues = chunk
+          .split('\n\n')
+          .filter(line => line.trim().startsWith('data:'))
+          .map((line) => {
+            const data = JSON.parse(line.replace('data: ', ''));
+            return data.content[0].text.value;
+          });
+
+        // append text values to the assistant message
+        setLastMessage((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, messageLogs, 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="vstack gap-4 pb-4">
+          { messageLogs.map(message => (
+            <MessageCard key={message.id} role={message.isUserMessage ? 'user' : 'assistant'}>{message.content}</MessageCard>
+          )) }
+          { lastMessage != null && (
+            <MessageCard role="assistant">{lastMessage.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="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 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>;
+};

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

@@ -0,0 +1,22 @@
+import type { ChangeEventHandler, DetailedHTMLProps, TextareaHTMLAttributes } from 'react';
+import { useCallback } from 'react';
+
+type Props = DetailedHTMLProps<TextareaHTMLAttributes<HTMLTextAreaElement>, HTMLTextAreaElement>;
+
+export const ResizableTextarea = (props: Props): JSX.Element => {
+
+  const { onChange: _onChange, ...rest } = props;
+
+  const onChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback((e) => {
+    _onChange?.(e);
+
+    // auto resize
+    // refs: https://zenn.dev/soma3134/articles/1e2fb0eab75b2d
+    e.target.style.height = 'auto';
+    e.target.style.height = `${e.target.scrollHeight + 4}px`;
+  }, [_onChange]);
+
+  return (
+    <textarea onChange={onChange} {...rest} />
+  );
+};

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

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

+ 8 - 2
apps/app/src/server/routes/apiv3/openai/index.ts

@@ -1,12 +1,18 @@
 import express from 'express';
 
-import { chatHandlersFactory } from './chat';
+import { postMessageHandlersFactory } from './message';
 import { rebuildVectorStoreHandlersFactory } from './rebuild-vector-store';
+import { createThreadHandlersFactory } from './thread';
 
 const router = express.Router();
 
 module.exports = (crowi) => {
-  router.post('/chat', chatHandlersFactory(crowi));
   router.post('/rebuild-vector-store', rebuildVectorStoreHandlersFactory(crowi));
+
+  // create thread
+  router.post('/thread', createThreadHandlersFactory(crowi));
+  // post message and return streaming with SSE
+  router.post('/message', postMessageHandlersFactory(crowi));
+
   return router;
 };

+ 85 - 0
apps/app/src/server/routes/apiv3/openai/message.ts

@@ -0,0 +1,85 @@
+import assert from 'assert';
+
+import type { Request, RequestHandler, Response } from 'express';
+import type { ValidationChain } from 'express-validator';
+import { body } from 'express-validator';
+import type { AssistantStream } from 'openai/lib/AssistantStream';
+import type { MessageDelta } from 'openai/resources/beta/threads/messages.mjs';
+
+import type Crowi from '~/server/crowi';
+import { openaiClient } from '~/server/service/openai';
+import { getOrCreateChatAssistant } from '~/server/service/openai/assistant';
+import loggerFactory from '~/utils/logger';
+
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+
+
+const logger = loggerFactory('growi:routes:apiv3:openai:chat');
+
+
+type ReqBody = {
+  userMessage: string,
+  threadId?: string,
+}
+
+type Req = Request<undefined, Response, ReqBody>
+
+type PostMessageHandlersFactory = (crowi: Crowi) => RequestHandler[];
+
+export const postMessageHandlersFactory: PostMessageHandlersFactory = (crowi) => {
+  const accessTokenParser = require('../../../middlewares/access-token-parser')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+
+  const validator: ValidationChain[] = [
+    body('userMessage').isString().withMessage('userMessage must be string'),
+    body('threadId').isString().withMessage('threadId must be string'),
+  ];
+
+  return [
+    accessTokenParser, loginRequiredStrictly, validator, apiV3FormValidator,
+    async(req: Req, res: Response) => {
+
+      const threadId = req.body.threadId;
+
+      assert(threadId != null);
+
+      let stream: AssistantStream;
+
+      try {
+        const assistant = await getOrCreateChatAssistant();
+
+        const thread = await openaiClient.beta.threads.retrieve(threadId);
+
+        stream = openaiClient.beta.threads.runs.stream(thread.id, {
+          assistant_id: assistant.id,
+          additional_messages: [{ role: 'assistant', content: req.body.userMessage }],
+        });
+
+      }
+      catch (err) {
+        logger.error(err);
+        return res.status(500).send(err);
+      }
+
+      res.writeHead(200, {
+        'Content-Type': 'text/event-stream;charset=utf-8',
+        'Cache-Control': 'no-cache, no-transform',
+      });
+
+      const messageDeltaHandler = (delta: MessageDelta) => {
+        res.write(`data: ${JSON.stringify(delta)}\n\n`);
+      };
+
+      stream.on('messageDelta', messageDeltaHandler);
+      stream.once('messageDone', () => {
+        stream.off('messageDelta', messageDeltaHandler);
+        res.end();
+      });
+      stream.once('error', (err) => {
+        logger.error(err);
+        stream.off('messageDelta', messageDeltaHandler);
+        res.end();
+      });
+    },
+  ];
+};

+ 10 - 29
apps/app/src/server/routes/apiv3/openai/chat.ts → apps/app/src/server/routes/apiv3/openai/thread.ts

@@ -3,51 +3,42 @@ import type { ValidationChain } from 'express-validator';
 import { body } from 'express-validator';
 
 import type Crowi from '~/server/crowi';
-import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
-import { certifyAiService } from '~/server/middlewares/certify-ai-service';
-import { configManager } from '~/server/service/config-manager';
 import { openaiClient } from '~/server/service/openai';
-import { getOrCreateChatAssistant } from '~/server/service/openai/assistant';
 import loggerFactory from '~/utils/logger';
 
-
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 
 const logger = loggerFactory('growi:routes:apiv3:openai:chat');
 
-type ReqBody = {
+type CreateThreadReq = Request<undefined, ApiV3Response, {
   userMessage: string,
   threadId?: string,
-}
-
-type Req = Request<undefined, ApiV3Response, ReqBody>
+}>
 
-type ChatHandlersFactory = (crowi: Crowi) => RequestHandler[];
+type CreateThreadFactory = (crowi: Crowi) => RequestHandler[];
 
-export const chatHandlersFactory: ChatHandlersFactory = (crowi) => {
+export const createThreadHandlersFactory: CreateThreadFactory = (crowi) => {
   const accessTokenParser = require('../../../middlewares/access-token-parser')(crowi);
   const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
 
   const validator: ValidationChain[] = [
-    body('userMessage').isString().withMessage('userMessage must be string'),
     body('threadId').optional().isString().withMessage('threadId must be string'),
   ];
 
   return [
-    accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
-    async(req: Req, res: ApiV3Response) => {
-      const vectorStoreId = configManager.getConfig('crowi', 'app:openaiVectorStoreId');
+    accessTokenParser, loginRequiredStrictly, validator, apiV3FormValidator,
+    async(req: CreateThreadReq, res: ApiV3Response) => {
+
+      const vectorStoreId = process.env.OPENAI_VECTOR_STORE_ID;
       if (vectorStoreId == null) {
         return res.apiv3Err('OPENAI_VECTOR_STORE_ID is not setup', 503);
       }
 
       try {
-        const assistant = await getOrCreateChatAssistant();
-
         const threadId = req.body.threadId;
         const thread = threadId == null
           ? await openaiClient.beta.threads.create({
-            messages: [{ role: 'assistant', content: req.body.userMessage }],
             tool_resources: {
               file_search: {
                 vector_store_ids: [vectorStoreId],
@@ -56,17 +47,7 @@ export const chatHandlersFactory: ChatHandlersFactory = (crowi) => {
           })
           : await openaiClient.beta.threads.retrieve(threadId);
 
-        const run = await openaiClient.beta.threads.runs.createAndPoll(thread.id, { assistant_id: assistant.id });
-
-        if (run.status === 'completed') {
-          const messages = await openaiClient.beta.threads.messages.list(run.thread_id, {
-            limit: 1,
-            order: 'desc',
-          });
-          return res.apiv3({ messages });
-        }
-
-        return res.apiv3({});
+        return res.apiv3({ thread });
       }
       catch (err) {
         logger.error(err);

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

+ 2 - 0
packages/markdown-splitter/.eslintignore

@@ -0,0 +1,2 @@
+/dist/**
+/types/**

+ 5 - 0
packages/markdown-splitter/.eslintrc.cjs

@@ -0,0 +1,5 @@
+module.exports = {
+  extends: [
+    'weseek/react',
+  ],
+};

+ 1 - 0
packages/markdown-splitter/.gitignore

@@ -0,0 +1 @@
+/dist

+ 43 - 0
packages/markdown-splitter/package.json

@@ -0,0 +1,43 @@
+{
+  "name": "@growi/markdown-splitter",
+  "version": "1.0.0",
+  "license": "MIT",
+  "private": "true",
+  "type": "module",
+  "module": "dist/index.js",
+  "types": "dist/index.d.ts",
+  "files": [
+    "dist"
+  ],
+  "main": "dist/index.cjs",
+  "exports": {
+    ".": {
+      "import": "./dist/index.js",
+      "require": "./dist/index.cjs"
+    }
+  },
+  "scripts": {
+    "build": "vite build",
+    "clean": "shx rm -rf dist",
+    "dev": "vite build --mode dev",
+    "watch": "yarn dev -w --emptyOutDir=false",
+    "lint:js": "yarn eslint **/*.{js,ts}",
+    "lint:typecheck": "tsc",
+    "lint": "npm-run-all -p lint:*",
+    "test": "vitest run --coverage"
+  },
+  "devDependencies": {
+    "eslint-plugin-regex": "^1.8.0",
+    "hast-util-sanitize": "^4.1.0",
+    "pako": "^2.1.0",
+    "throttle-debounce": "^5.0.0",
+    "unified": "^10.1.2",
+    "unist-util-visit": "^4.0.0"
+  },
+  "peerDependencies": {
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0"
+  },
+  "dependencies": {
+  }
+}

+ 1 - 0
packages/markdown-splitter/src/index.ts

@@ -0,0 +1 @@
+export * from './services/markdown-splitter';

+ 106 - 0
packages/markdown-splitter/src/services/markdown-splitter.ts

@@ -0,0 +1,106 @@
+export type Chunk = {
+  label: string;
+  text: string;
+};
+
+/**
+ * Processes and adds a new chunk to the chunks array if content is not empty.
+ * Clears the contentLines array after processing.
+ * @param chunks - The array to store chunks.
+ * @param contentLines - The array of content lines.
+ * @param label - The label for the content chunk.
+ */
+function processPendingContent(chunks: Chunk[], contentLines: string[], label: string) {
+  const text = contentLines.join('\n').trimEnd();
+  if (text !== '') {
+    chunks.push({ label, text });
+  }
+  contentLines.length = 0; // Clear the contentLines array
+}
+
+/**
+ * Updates the section numbers based on the heading depth and returns the updated section label.
+ * Handles non-consecutive heading levels by initializing missing levels with 1.
+ * @param sectionNumbers - The current section numbers.
+ * @param depth - The depth of the heading (e.g., # is depth 1).
+ * @returns The updated section label.
+ */
+function updateSectionNumbers(sectionNumbers: number[], depth: number): string {
+  if (depth > sectionNumbers.length) {
+    // If depth increases, initialize missing levels with 1
+    while (sectionNumbers.length < depth) {
+      sectionNumbers.push(1);
+    }
+  }
+  else if (depth === sectionNumbers.length) {
+    // Same level, increment the last number
+    sectionNumbers[depth - 1]++;
+  }
+  else {
+    // Depth decreases, remove deeper levels and increment current level
+    sectionNumbers.splice(depth);
+    sectionNumbers[depth - 1]++;
+  }
+  return sectionNumbers.join('-');
+}
+
+/**
+ * Splits Markdown text into labeled chunks, considering content that may start before any headers
+ * and handling non-consecutive heading levels. Preserves list indentation and leading spaces while
+ * reducing unnecessary line breaks. Ensures that no empty line is added between sections.
+ * @param markdown - The input Markdown string.
+ * @returns An array of labeled chunks.
+ */
+export function splitMarkdownIntoChunks(markdown: string): Chunk[] {
+  const chunks: Chunk[] = [];
+  const sectionNumbers: number[] = [];
+
+  if (typeof markdown !== 'string' || markdown.trim() === '') {
+    return chunks;
+  }
+
+  const lines = markdown.split('\n');
+  const contentLines: string[] = [];
+  let currentLabel = '';
+  let previousLineEmpty = false;
+
+  for (const line of lines) {
+    const trimmedLine = line.trim();
+
+    if (trimmedLine.startsWith('#')) {
+      // Process any pending content before starting a new section
+      if (contentLines.length > 0) {
+        const contentLabel = currentLabel !== '' ? `${currentLabel}-content` : '0-content';
+        processPendingContent(chunks, contentLines, contentLabel);
+      }
+
+      // Match heading level and text
+      const headerMatch = trimmedLine.match(/^(#+)\s+(.*)/);
+      if (headerMatch) {
+        const headingDepth = headerMatch[1].length;
+        currentLabel = updateSectionNumbers(sectionNumbers, headingDepth);
+        chunks.push({ label: `${currentLabel}-heading`, text: line });
+      }
+    }
+    else if (trimmedLine === '') {
+      // Handle empty lines to avoid multiple consecutive empty lines
+      if (!previousLineEmpty && contentLines.length > 0) {
+        contentLines.push('');
+        previousLineEmpty = true;
+      }
+    }
+    else {
+      // Add non-empty lines to the current content
+      contentLines.push(line);
+      previousLineEmpty = false;
+    }
+  }
+
+  // Process any remaining content after the last line
+  if (contentLines.length > 0) {
+    const contentLabel = currentLabel !== '' ? `${currentLabel}-content` : '0-content';
+    processPendingContent(chunks, contentLines, contentLabel);
+  }
+
+  return chunks;
+}

+ 252 - 0
packages/markdown-splitter/test/index.spec.ts

@@ -0,0 +1,252 @@
+import type { Chunk } from '../src/services/markdown-splitter';
+import { splitMarkdownIntoChunks } from '../src/services/markdown-splitter';
+
+describe('splitMarkdownIntoChunks', () => {
+
+  test('handles empty markdown string', () => {
+    const markdown = '';
+    const expected: Chunk[] = [];
+    const result = splitMarkdownIntoChunks(markdown);
+    expect(result).toEqual(expected);
+  });
+
+  test('handles markdown with only content and no headers', () => {
+    const markdown = `This is some content without any headers.
+It spans multiple lines.
+
+Another paragraph.
+    `;
+    const expected: Chunk[] = [
+      {
+        label: '0-content',
+        text: 'This is some content without any headers.\nIt spans multiple lines.\n\nAnother paragraph.',
+      },
+    ];
+    const result = splitMarkdownIntoChunks(markdown);
+    expect(result).toEqual(expected);
+  });
+
+  test('handles markdown starting with a header', () => {
+    const markdown = `
+# Header 1
+Content under header 1.
+
+## Header 1.1
+Content under header 1.1.
+
+# Header 2
+Content under header 2.
+    `;
+    const expected: Chunk[] = [
+      { label: '1-heading', text: '# Header 1' },
+      { label: '1-content', text: 'Content under header 1.' },
+      { label: '1-1-heading', text: '## Header 1.1' },
+      { label: '1-1-content', text: 'Content under header 1.1.' },
+      { label: '2-heading', text: '# Header 2' },
+      { label: '2-content', text: 'Content under header 2.' },
+    ];
+    const result = splitMarkdownIntoChunks(markdown);
+    expect(result).toEqual(expected);
+  });
+
+  test('handles markdown with non-consecutive heading levels', () => {
+    const markdown = `
+Introduction without a header.
+
+# Chapter 1
+Content of chapter 1.
+
+### Section 1.1.1
+Content of section 1.1.1.
+
+## Section 1.2
+Content of section 1.2.
+
+# Chapter 2
+Content of chapter 2.
+
+## Section 2.1
+Content of section 2.1.
+    `;
+    const expected: Chunk[] = [
+      {
+        label: '0-content',
+        text: 'Introduction without a header.',
+      },
+      {
+        label: '1-heading',
+        text: '# Chapter 1',
+      },
+      {
+        label: '1-content',
+        text: 'Content of chapter 1.',
+      },
+      {
+        label: '1-1-1-heading',
+        text: '### Section 1.1.1',
+      },
+      {
+        label: '1-1-1-content',
+        text: 'Content of section 1.1.1.',
+      },
+      {
+        label: '1-2-heading',
+        text: '## Section 1.2',
+      },
+      {
+        label: '1-2-content',
+        text: 'Content of section 1.2.',
+      },
+      {
+        label: '2-heading',
+        text: '# Chapter 2',
+      },
+      {
+        label: '2-content',
+        text: 'Content of chapter 2.',
+      },
+      {
+        label: '2-1-heading',
+        text: '## Section 2.1',
+      },
+      {
+        label: '2-1-content',
+        text: 'Content of section 2.1.',
+      },
+    ];
+    const result = splitMarkdownIntoChunks(markdown);
+    expect(result).toEqual(expected);
+  });
+
+  test('handles markdown with skipped heading levels', () => {
+    const markdown = `
+# Header 1
+Content under header 1.
+
+#### Header 1.1.1.1
+Content under header 1.1.1.1.
+
+## Header 1.2
+Content under header 1.2.
+
+# Header 2
+Content under header 2.
+    `;
+    const expected: Chunk[] = [
+      { label: '1-heading', text: '# Header 1' },
+      { label: '1-content', text: 'Content under header 1.' },
+      { label: '1-1-1-1-heading', text: '#### Header 1.1.1.1' },
+      { label: '1-1-1-1-content', text: 'Content under header 1.1.1.1.' },
+      { label: '1-2-heading', text: '## Header 1.2' },
+      { label: '1-2-content', text: 'Content under header 1.2.' },
+      { label: '2-heading', text: '# Header 2' },
+      { label: '2-content', text: 'Content under header 2.' },
+    ];
+    const result = splitMarkdownIntoChunks(markdown);
+    expect(result).toEqual(expected);
+  });
+
+  test('handles malformed headings', () => {
+    const markdown = `
+# Header 1
+Content under header 1.
+
+#### Header 1.1.1.1
+Content under header 1.1.1.1.
+    `;
+    const expected: Chunk[] = [
+      { label: '1-heading', text: '# Header 1' },
+      { label: '1-content', text: 'Content under header 1.' },
+      { label: '1-1-1-1-heading', text: '#### Header 1.1.1.1' },
+      { label: '1-1-1-1-content', text: 'Content under header 1.1.1.1.' },
+    ];
+    const result = splitMarkdownIntoChunks(markdown);
+    expect(result).toEqual(expected);
+  });
+
+  test('handles multiple content blocks before any headers', () => {
+    const markdown = `
+This is the first paragraph without a header.
+
+This is the second paragraph without a header.
+
+# Header 1
+Content under header 1.
+    `;
+    const expected: Chunk[] = [
+      {
+        label: '0-content',
+        text: 'This is the first paragraph without a header.\n\nThis is the second paragraph without a header.',
+      },
+      { label: '1-heading', text: '# Header 1' },
+      { label: '1-content', text: 'Content under header 1.' },
+    ];
+    const result = splitMarkdownIntoChunks(markdown);
+    expect(result).toEqual(expected);
+  });
+
+  test('handles markdown with only headers and no content', () => {
+    const markdown = `
+# Header 1
+
+## Header 1.1
+
+### Header 1.1.1
+    `;
+    const expected: Chunk[] = [
+      { label: '1-heading', text: '# Header 1' },
+      { label: '1-1-heading', text: '## Header 1.1' },
+      { label: '1-1-1-heading', text: '### Header 1.1.1' },
+    ];
+    const result = splitMarkdownIntoChunks(markdown);
+    expect(result).toEqual(expected);
+  });
+
+  test('handles markdown with mixed content and headers', () => {
+    const markdown = `
+# Header 1
+Content under header 1.
+
+## Header 1.1
+Content under header 1.1.
+Another piece of content.
+
+# Header 2
+Content under header 2.
+    `;
+    const expected: Chunk[] = [
+      { label: '1-heading', text: '# Header 1' },
+      { label: '1-content', text: 'Content under header 1.' },
+      { label: '1-1-heading', text: '## Header 1.1' },
+      { label: '1-1-content', text: 'Content under header 1.1.\nAnother piece of content.' },
+      { label: '2-heading', text: '# Header 2' },
+      { label: '2-content', text: 'Content under header 2.' },
+    ];
+    const result = splitMarkdownIntoChunks(markdown);
+    expect(result).toEqual(expected);
+  });
+
+  test('preserves list indentation and reduces unnecessary line breaks', () => {
+    const markdown = `
+# Header 1
+Content under header 1.
+
+- Item 1
+  - Subitem 1
+- Item 2
+
+
+# Header 2
+Content under header 2.
+    `;
+    const expected: Chunk[] = [
+      { label: '1-heading', text: '# Header 1' },
+      { label: '1-content', text: 'Content under header 1.\n\n- Item 1\n  - Subitem 1\n- Item 2' },
+      { label: '2-heading', text: '# Header 2' },
+      { label: '2-content', text: 'Content under header 2.' },
+    ];
+    const result = splitMarkdownIntoChunks(markdown);
+    expect(result).toEqual(expected);
+  });
+
+});

+ 16 - 0
packages/markdown-splitter/tsconfig.json

@@ -0,0 +1,16 @@
+{
+  "$schema": "http://json.schemastore.org/tsconfig",
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "baseUrl": ".",
+    "paths": {
+      "~/*": ["./src/*"]
+    },
+    "types": [
+      "vitest/globals"
+    ]
+  },
+  "include": [
+    "src", "test"
+  ]
+}

+ 39 - 0
packages/markdown-splitter/vite.config.ts

@@ -0,0 +1,39 @@
+import path from 'path';
+
+import glob from 'glob';
+import { nodeExternals } from 'rollup-plugin-node-externals';
+import { defineConfig } from 'vite';
+import dts from 'vite-plugin-dts';
+
+// https://vitejs.dev/config/
+export default defineConfig({
+  plugins: [
+    dts({
+      copyDtsFiles: true,
+    }),
+    {
+      ...nodeExternals({
+        devDeps: true,
+        builtinsPrefix: 'ignore',
+      }),
+      enforce: 'pre',
+    },
+  ],
+  build: {
+    outDir: 'dist',
+    sourcemap: true,
+    lib: {
+      entry: glob.sync(path.resolve(__dirname, 'src/**/*.ts'), {
+        ignore: '**/*.spec.ts',
+      }),
+      name: 'core-libs',
+      formats: ['es', 'cjs'],
+    },
+    rollupOptions: {
+      output: {
+        preserveModules: true,
+        preserveModulesRoot: 'src',
+      },
+    },
+  },
+});

+ 25 - 0
packages/markdown-splitter/vitest.config.ts

@@ -0,0 +1,25 @@
+import tsconfigPaths from 'vite-tsconfig-paths';
+import { defineConfig, coverageConfigDefaults } from 'vitest/config';
+
+export default defineConfig({
+  plugins: [
+    tsconfigPaths(),
+  ],
+  test: {
+    environment: 'node',
+    clearMocks: true,
+    globals: true,
+    coverage: {
+      exclude: [
+        ...coverageConfigDefaults.exclude,
+        'src/**/index.ts',
+      ],
+      thresholds: {
+        statements: 100,
+        branches: 100,
+        lines: 100,
+        functions: 100,
+      },
+    },
+  },
+});

+ 3 - 0
yarn.lock

@@ -2157,6 +2157,9 @@
     react "^18.2.0"
     react-dom "^18.2.0"
 
+"@growi/markdown-splitter@link:packages/markdown-splitter":
+  version "1.0.0"
+
 "@growi/pluginkit@link:packages/pluginkit":
   version "1.0.1"
   dependencies: