Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

imprv: OpenAI chat by SSE #9058

Merged
merged 22 commits into from
Oct 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
eafc2f4
implement new routes
yuki-takei Aug 29, 2024
3ffc33c
WIP
yuki-takei Aug 29, 2024
de5ecd4
Merge branch 'feat/openai-vector-searching' into imprv/openai-chat-sse
yuki-takei Sep 5, 2024
3f9bf1c
update mergify configurations
yuki-takei Sep 5, 2024
24139f5
Revert "update mergify configurations"
yuki-takei Sep 5, 2024
cc41677
Merge branch 'feat/openai-vector-searching' into imprv/openai-chat-sse
yuki-takei Sep 5, 2024
5601764
Merge branch 'feat/openai-vector-searching' into imprv/openai-chat-sse
yuki-takei Sep 5, 2024
44a4d07
Merge remote-tracking branch 'origin/feat/openai-vector-searching' in…
yuki-takei Sep 13, 2024
90b034e
Merge remote-tracking branch 'origin/feat/openai-vector-searching' in…
yuki-takei Sep 24, 2024
d8de69a
WIP: implement SWR hook to subscribe
yuki-takei Sep 24, 2024
23cb3c6
Merge branch 'feat/openai-vector-searching' into imprv/openai-chat-sse
yuki-takei Oct 3, 2024
3be91db
implement SSE
yuki-takei Oct 4, 2024
d1b76af
clean code
yuki-takei Oct 4, 2024
5350fe9
WIP: output SSE data to console with fetch
yuki-takei Oct 4, 2024
9b6ba3c
WIP: extract message chunk
yuki-takei Oct 4, 2024
0d9f96c
improve styles
yuki-takei Oct 4, 2024
e70feb9
clean code
yuki-takei Oct 4, 2024
d7870ba
implement resizable textarea
yuki-takei Oct 4, 2024
b8724b7
improve state modification
yuki-takei Oct 4, 2024
bec7360
impl shortcut key
yuki-takei Oct 4, 2024
3978788
Merge remote-tracking branch 'origin/feat/openai-vector-searching' in…
yuki-takei Oct 7, 2024
df65912
apply resize and react-hook-form
yuki-takei Oct 7, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
@use '@growi/core-styles/scss/bootstrap/init' as bs;
@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 {
.btn-submit {
@include btn-muted.colorize(bs.$purple, bs.$purple);
}
}
214 changes: 167 additions & 47 deletions apps/app/src/client/components/RagSearch/RagSearchModal.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import React, { useState } from 'react';
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 { 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 './RagSearchModal.module.scss';

const moduleClass = styles['rag-search-modal'];

const logger = loggerFactory('growi:clinet:components:RagSearchModal');

Expand All @@ -18,76 +24,190 @@ type Message = {
isUserMessage?: boolean,
}

type FormData = {
input: string;
};

const RagSearchModal = (): JSX.Element => {

const [input, setInput] = useState('');
const form = useForm<FormData>({
defaultValues: {
input: '',
},
});

const [threadId, setThreadId] = useState<string | undefined>();
const [messages, setMessages] = useState<Message[]>([]);
const [messageLogs, setMessageLogs] = useState<Message[]>([]);
const [lastMessage, setLastMessage] = 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]);
const isOpened = ragSearchModalData?.isOpened ?? false;

setInput('');
useEffect(() => {
// clear states when the modal is closed
if (!isOpened) {
setMessageLogs([]);
setThreadId(undefined);
}
}, [isOpened]);

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,
};
});
useEffect(() => {
// do nothing when the modal is closed or threadId is already set
if (!isOpened || threadId != null) {
return;
}

setMessages(msgs => [...msgs, ...newMessages]);
setThreadId(assistantMessageData.data[0].threadId);
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();
}, [isOpened, 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 (
<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>

<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">
{ messages.map(message => (
{ messageLogs.map(message => (
<MessageCard key={message.id} right={message.isUserMessage}>{message.content}</MessageCard>
)) }
{ lastMessage != null && (
<MessageCard>{lastMessage.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>
<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>
)}
</div>
</ModalBody>
</Modal>
Expand Down
22 changes: 22 additions & 0 deletions apps/app/src/client/components/RagSearch/ResizableTextArea.tsx
Original file line number Diff line number Diff line change
@@ -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} />
);
};
10 changes: 8 additions & 2 deletions apps/app/src/server/routes/apiv3/openai/index.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Loading
Loading