Skip to content

Commit

Permalink
feat: chat ui
Browse files Browse the repository at this point in the history
  • Loading branch information
HuberTRoy committed Nov 1, 2024
1 parent 906463d commit c173548
Show file tree
Hide file tree
Showing 3 changed files with 275 additions and 65 deletions.
64 changes: 62 additions & 2 deletions components/common/chatUi/chatUi.less
Original file line number Diff line number Diff line change
Expand Up @@ -281,8 +281,6 @@
}

&-main {
height: ~"min(480px, calc(100vh - 400px))";
overflow: auto;
}

&-input {
Expand Down Expand Up @@ -315,6 +313,68 @@
box-shadow: 0px 9px 28px 8px #0000000D, 0px 6px 16px 0px #00000014, 0px 3px 6px -4px #0000001F;
}
}

&-conversation-message {
display: flex;
flex-direction: column;
gap: 16px;
height: ~"min(480px, calc(100vh - 400px))";
overflow: auto;
padding: 28px 20px;

&__item {
display: flex;
gap: 8px;

&-span {
padding: 12px;
border-radius: 8px;
max-width: 320px;
.subql-markdown-preview {
display: flex;
flex-direction: column;
gap: 1em;
p {
margin: 0;
word-break: break-word;
}
}
}
}

&__assistant {
.subql-chatbox-conversation-message__item-span {
background: var(--sq-gray200);
font-size: 14px;
border-top-left-radius: 0;
}

&--lastOne&--loading {
.subql-chatbox-conversation-message__item-span {
.subql-markdown-preview {
&::after {
content: "";
align-self: flex-end;
margin-left: 8px;
animation: pulseCursor 1s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
}
}
}
}

&__user {
justify-content: flex-end;
.subql-chatbox-conversation-message__item-span {
background: var(--sq-gray800);
font-size: 14px;
border-bottom-right-radius: 0;
.subql-markdown-preview {
color: #fff;
}
}
}
}
}

@keyframes pulseCursor {
Expand Down
233 changes: 170 additions & 63 deletions components/common/chatUi/chatUi.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { cloneDeep, isString } from 'lodash-es';
import Address from '../address';
import Markdown from '../markdown/Markdown';
import './chatUi.less';
import { chatWithStream, ConversationProperty, Message } from 'components/utilities/chatWithStream';

const indexerName: { [key in string]: string } = {
'0xd0af1919af890cfdd8d12be5cf1b1421224fc29a': 'Mainnet Operator',
Expand All @@ -44,29 +45,6 @@ export interface ChatBoxProps {
model?: string;
}

export type AiMessageType = 'text' | 'image_url';

export type AiMessageRole = 'assistant' | 'user' | 'system';

export interface Content {
type: AiMessageType;
text?: string;
image_url?: string;
}

export interface Message {
role: AiMessageRole;
content: string | Content[];
}

export interface ConversationProperty {
id: string;
name: string;
chatUrl: string;
messages: Message[];
prompt: string;
}

export interface ConversationItemProps {
property: ConversationProperty;
active?: boolean;
Expand Down Expand Up @@ -120,10 +98,20 @@ export const ConversationMessage = forwardRef<
{ scrollToBottom: () => void },
{ property: ConversationProperty; answerStatus: ChatBotAnswerStatus; version?: 'chat' | 'chatbox' }
>(({ property, answerStatus, version = 'chat' }, ref) => {
const bem = useBem(version === 'chat' ? 'subql-chat-conversation-message' : 'subql-chatbox-coversation-message');
const bem = useBem(version === 'chat' ? 'subql-chat-conversation-message' : 'subql-chatbox-conversation-message');
const outerRef = useRef<HTMLDivElement>(null);
useImperativeHandle(ref, () => ({
scrollToBottom: () => {
scrollToBottom: (onlyWhenReachBottom = false) => {
if (onlyWhenReachBottom && outerRef.current) {
// 22 = 1em + line height
const ifReachBottom =
outerRef.current?.scrollTop >= outerRef.current?.scrollHeight - outerRef.current?.clientHeight - 22;
if (ifReachBottom) {
outerRef.current?.scrollTo(0, outerRef.current?.scrollHeight);
}

return;
}
outerRef.current?.scrollTo(0, outerRef.current?.scrollHeight);
},
}));
Expand Down Expand Up @@ -160,9 +148,7 @@ export const ConversationMessage = forwardRef<
) : (
<>
{message.role === 'assistant' ? (
<div>
<img src=""></img>
</div>
<img src="https://static.subquery.network/logo-with-bg.svg" width={40} height={40}></img>
) : (
''
)}
Expand All @@ -178,29 +164,8 @@ export const ConversationMessage = forwardRef<
</div>
);
});

ConversationMessage.displayName = 'ConversationMessage';

// maybe later support custom workspace name
const workspaceName = 'subql-chat-workspace';

export const chatWithStream = async (url: string, body: { messages: Message[]; model?: string }) => {
const { model = 'gemma2' } = body;
const res = await fetch(url, {
headers: {
accept: 'text/event-stream',
'Content-Type': 'application/json',
},
method: 'POST',
body: JSON.stringify({
model,
messages: body.messages,
stream: true,
}),
});
return res;
};

export const ChatUi: FC<ChatUiProps> = ({ chatUrl, prompt, className, placeholder, width, height, model }) => {
const bem = useBem('subql-chat');
const [chats, setChats] = React.useState<ConversationItemProps['property'][]>([]);
Expand Down Expand Up @@ -590,9 +555,150 @@ const ChatBoxIcon: FC<{ className?: string }> = ({ className }) => (
);

// maybe split to other file.
export const ChatBox: FC<ChatBoxProps> = () => {
export const ChatBox: FC<ChatBoxProps> = (props) => {
const { chatUrl, prompt = '', model } = props;
const [popoverOpen, setPopoverOpen] = useState(true);
const bem = useBem('subql-chatbox');
const [currentInput, setCurrentInput] = useState('');
const inputRef = useRef<InputRef>(null);
const messageRef = useRef<{ scrollToBottom: (argv?: boolean) => void }>(null);
const [currentChat, setCurrentChat] = useState<ConversationItemProps['property']>({
messages: [
{
role: 'assistant',
content: 'Hi, I’m SubQuery AI, how can I help? you can ask me anything you want',
type: 'welcome',
},
],
id: '0',
name: 'SubQuery AI',
chatUrl,
prompt,
});
const [answerStatus, setAnswerStatus] = useState<ChatBotAnswerStatus>(ChatBotAnswerStatus.Loading);

const pushNewMsgToChat = async (
newChat: ConversationProperty,
newMessage: Message,
curChat?: ConversationItemProps['property'],
) => {
const cur = curChat || currentChat;
if (!cur) return;

setCurrentChat({
...newChat,
messages: [...newChat.messages, newMessage],
});
};

const sendMessage = async () => {
const curChat = currentChat;
if (!currentInput) {
return;
}

setAnswerStatus(ChatBotAnswerStatus.Loading);
try {
const newMessage = {
role: 'user' as const,
content: currentInput,
};

const newChat = {
...curChat,
messages: [...curChat.messages, newMessage].filter((i) => i.content),
name: curChat.messages.length ? curChat.name : currentInput.slice(0, 40),
};
newChat.chatUrl = newChat.messages.length - 1 > 0 ? newChat.chatUrl : chatUrl;
newChat.prompt = newChat.prompt || prompt || '';

const robotAnswer: Message = {
role: 'assistant' as const,
content: '',
};

setCurrentInput('');
await pushNewMsgToChat(newChat, robotAnswer, curChat);
messageRef.current?.scrollToBottom();
// set user's message first, then get the response
const res = await chatWithStream(newChat.chatUrl, {
messages: newChat.prompt
? [{ role: 'system' as const, content: newChat.prompt }, ...newChat.messages]
: newChat.messages,
model,
});

if (res.status === 200 && res.body) {
const decoder = new TextDecoder();
const reader = res.body.getReader();
let invalidJson = '';

while (true) {
const { value, done } = await reader.read();
const chunkValue = decoder.decode(value);

if (done || !chunkValue) {
break;
}

const parts = chunkValue.split('\n\n');
for (const part of parts) {
if (invalidJson) {
try {
invalidJson += part;
const parsed: { choices: { delta: { content: string } }[] } = JSON.parse(invalidJson);
robotAnswer.content += parsed?.choices?.[0]?.delta?.content;

await pushNewMsgToChat(newChat, robotAnswer, curChat);
console.warn(messageRef);
messageRef.current?.scrollToBottom(true);
invalidJson = '';
} catch (e) {
// handle it until
}
continue;
}

const partWithHandle = part.startsWith('data: ') ? part.slice(6, part.length).trim() : part;

if (partWithHandle) {
try {
const parsed: { choices: { delta: { content: string } }[] } = JSON.parse(partWithHandle);
robotAnswer.content += parsed?.choices?.[0]?.delta?.content;

await pushNewMsgToChat(newChat, robotAnswer, curChat);
messageRef.current?.scrollToBottom(true);
} catch (e) {
invalidJson += partWithHandle;
}
}
}
}

if (invalidJson) {
try {
const parsed: { choices: { delta: { content: string } }[] } = JSON.parse(invalidJson);
robotAnswer.content += parsed?.choices?.[0]?.delta?.content;

await pushNewMsgToChat(newChat, robotAnswer, curChat);
} catch (e) {
console.warn('Reach this code', invalidJson);
// to reach this code, it means the response is not valid or the code have something wrong.
}
}
} else {
robotAnswer.content = 'Sorry, The Server is not available now.';
await pushNewMsgToChat(newChat, robotAnswer, curChat);
setAnswerStatus(ChatBotAnswerStatus.Error);
}
inputRef.current?.focus();
setAnswerStatus(ChatBotAnswerStatus.Success);
} catch (e) {
console.error(e);
inputRef.current?.focus();
setAnswerStatus(ChatBotAnswerStatus.Error);
}
};

return (
<Popover
Expand All @@ -619,28 +725,29 @@ export const ChatBox: FC<ChatBoxProps> = () => {
</div>
<div className={clsx(bem('content-main'))}>
<ConversationMessage
property={{
messages: [
{
role: 'assistant',
content: 'Welcome to SubQuery AI, how can I help you?',
},
],
id: '0',
name: 'SubQuery AI',
chatUrl: '',
prompt: '',
}}
answerStatus={ChatBotAnswerStatus.Success}
property={currentChat}
answerStatus={answerStatus}
version="chatbox"
ref={messageRef}
></ConversationMessage>
</div>
<div className={clsx(bem('content-bottom'))}>
<Input
ref={inputRef}
className={clsx(bem('content-input'))}
placeholder="Ask a question..."
value={currentInput}
onChange={(e) => {
setCurrentInput(e.target.value);
}}
onKeyUp={(e) => {
if (e.key === 'Enter') {
sendMessage();
}
}}
suffix={
<BsFillArrowUpCircleFill
onClick={() => sendMessage()}
style={{
color: 'var(--sq-gray300)',
fontSize: 32,
Expand Down
Loading

0 comments on commit c173548

Please sign in to comment.