From 24d55d8f74e2dd757f55fa91f7bbb9b886e9a58a Mon Sep 17 00:00:00 2001 From: waynelwz <100347187+waynelwz@users.noreply.github.com> Date: Wed, 6 Dec 2023 17:14:14 +0800 Subject: [PATCH] update(console): support client serving and optimize ui (#3065) --- console/client/App.tsx | 21 +- console/client/ServingPage.tsx | 19 +- console/client/components/ChatGroup.tsx | 388 ++++++++++++++++++ console/client/pages/llm/LLMChat.tsx | 2 +- .../starwhale-ui/src/Link/ButtonLink.tsx | 7 +- .../src/Popover/PopoverContainer.tsx | 8 +- .../src/Search/FilterRenderer.tsx | 24 +- .../starwhale-ui/src/Search/Search.tsx | 6 +- .../src/Search/components/FieldDefault.tsx | 3 +- .../packages/starwhale-ui/src/Search/types.ts | 1 + .../src/Serving/components/ChatGroup.tsx | 69 +--- .../starwhale-ui/src/Serving/store/chat.ts | 6 +- 12 files changed, 464 insertions(+), 90 deletions(-) create mode 100644 console/client/components/ChatGroup.tsx diff --git a/console/client/App.tsx b/console/client/App.tsx index 5cfa4351c9..fa5172d124 100644 --- a/console/client/App.tsx +++ b/console/client/App.tsx @@ -1,19 +1,24 @@ import React from 'react' import { Provider as StyletronProvider } from 'styletron-react' -import { BaseProvider } from 'baseui' +import { BaseProvider, LocaleProvider } from 'baseui' import DeepTheme from '@starwhale/ui/theme' import { Client as Styletron } from 'styletron-engine-atomic' import { QueryClient, QueryClientProvider } from 'react-query' import ServingPage from './ServingPage' +import { initI18n } from '@/i18n' + +initI18n() export default function App(): any { return ( - - - - - - - + + + + + + + + + ) } diff --git a/console/client/ServingPage.tsx b/console/client/ServingPage.tsx index 9bc890b54a..9dca9b4b19 100644 --- a/console/client/ServingPage.tsx +++ b/console/client/ServingPage.tsx @@ -3,8 +3,9 @@ import axios from 'axios' import { useQuery } from 'react-query' import { Select } from '@starwhale/ui' import _ from 'lodash' -import LLMChat from './pages/llm/LLMChat' import { IApiSchema, InferenceType, ISpecSchema } from './schemas/api' +import { useChatStore } from '@starwhale/ui/Serving/store/chat' +import ChatGroup from './components/ChatGroup' const fetchSpec = async () => { const { data } = await axios.get('/api/spec') @@ -13,6 +14,7 @@ const fetchSpec = async () => { export default function ServingPage() { const useFetchSpec = useQuery('spec', fetchSpec) + const chatStore = useChatStore() const [spec, setSpec] = React.useState() const [currentApi, setCurrentApi] = React.useState() @@ -21,8 +23,20 @@ export default function ServingPage() { if (!useFetchSpec.data) { return } + const apiSpec = useFetchSpec.data.apis[0] + chatStore.newOrUpdateSession({ + job: { id: 'client' } as any, + type: apiSpec?.inference_type, + exposedLink: { + link: '', + type: 'WEB_HANDLER', + name: 'llm_chat', + }, + apiSpec: useFetchSpec.data.apis[0], + } as any) setSpec(useFetchSpec.data) setCurrentApi(useFetchSpec.data.apis[0]) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [useFetchSpec.data]) return ( @@ -44,7 +58,8 @@ export default function ServingPage() { }} /> )} - {currentApi?.inference_type === InferenceType.LLM_CHAT && } + {/* {currentApi?.inference_type === InferenceType.LLM_CHAT && } */} + {currentApi?.inference_type === InferenceType.LLM_CHAT && } ) } diff --git a/console/client/components/ChatGroup.tsx b/console/client/components/ChatGroup.tsx new file mode 100644 index 0000000000..e5e9c4f69a --- /dev/null +++ b/console/client/components/ChatGroup.tsx @@ -0,0 +1,388 @@ +import CasecadeResizer from '@starwhale/ui/AutoResizer/CascadeResizer' +import { InferenceType, useServingConfig } from '@starwhale/ui/Serving/store/config' +import { BusyPlaceholder } from '@starwhale/ui/BusyLoaderWrapper' +import Button, { ExtendButton } from '@starwhale/ui/Button' +import { useDomsScrollToBottom } from '@starwhale/ui/Serving/hooks/useScrollToBottom' +import { Fragment, startTransition, useMemo, useReducer, useRef, useState } from 'react' +import { useCountDown, useDebounceEffect, useInterval } from 'ahooks' +import useSubmitHandler from '@starwhale/ui/Serving/hooks/useSubmitHandler' +import { autoGrowTextArea } from '@starwhale/ui/Serving/utils' +import { ChatMessage, ChatSession, useChatStore as Store } from '@starwhale/ui/Serving/store/chat' +import { nanoid } from 'nanoid' +import { LAST_INPUT_KEY } from '@starwhale/ui/Serving/constant' +import useTranslation from '@/hooks/useTranslation' +import { LabelMedium } from 'baseui/typography' +import { Modal, ModalBody, ModalHeader } from 'baseui/modal' +import { StatefulSlider } from 'baseui/slider' +import { expandBorderRadius, expandPadding } from '@starwhale/ui/utils' + +export const CHAT_PAGE_SIZE = 15 +export const MAX_RENDER_MSG_COUNT = 45 +const isMobileScreen = false +type RenderMessage = ChatMessage & { preview?: boolean } +type StoreT = typeof Store + +export function createMessage(override: Partial): ChatMessage { + return { + id: nanoid(), + date: new Date().toLocaleString(), + role: 'user', + content: '', + ...override, + } +} + +function Chat({ + scrollRef, + inputRef, + setAutoScroll, + useChatStore, + session, +}: { + useChatStore: StoreT + scrollRef: any + inputRef: any + setAutoScroll: any + session: ChatSession +}) { + const chatStore = useChatStore() + const { job } = session.serving ?? {} + const [t] = useTranslation() + const isLoading = false + + // init + const [, setHitBottom] = useState(true) + // session message + const context: RenderMessage[] = useMemo(() => { + return session.mask.hideContext ? [] : (session.mask.context ?? [])?.slice() + }, [session.mask.context, session.mask.hideContext]) + // preview messages + const renderMessages = useMemo(() => { + return context.concat(session.messages as RenderMessage[]).concat( + isLoading + ? [ + { + ...createMessage({ + role: 'assistant', + content: '……', + }), + preview: true, + }, + ] + : [] + ) + }, [context, isLoading, session.messages]) + const [msgRenderIndex, _setMsgRenderIndex] = useState(Math.max(0, renderMessages.length - CHAT_PAGE_SIZE)) + function setMsgRenderIndex(newIndex: number) { + // eslint-disable-next-line no-param-reassign + newIndex = Math.min(renderMessages.length - CHAT_PAGE_SIZE, newIndex) + // eslint-disable-next-line no-param-reassign + newIndex = Math.max(0, newIndex) + _setMsgRenderIndex(newIndex) + } + const messages = useMemo(() => { + const endRenderIndex = Math.min(msgRenderIndex + 3 * CHAT_PAGE_SIZE, renderMessages.length) + return renderMessages.slice(msgRenderIndex, endRenderIndex) + }, [msgRenderIndex, renderMessages]) + + // scroll + const onChatBodyScroll = (e: HTMLElement) => { + startTransition(() => { + const bottomHeight = e.scrollTop + e.clientHeight + const edgeThreshold = e.clientHeight + + const isTouchTopEdge = e.scrollTop <= edgeThreshold + const isTouchBottomEdge = bottomHeight >= e.scrollHeight - edgeThreshold + const isHitBottom = bottomHeight >= e.scrollHeight - (isMobileScreen ? 4 : 10) + + const prevPageMsgIndex = msgRenderIndex - CHAT_PAGE_SIZE + const nextPageMsgIndex = msgRenderIndex + CHAT_PAGE_SIZE + + if (isTouchTopEdge && !isTouchBottomEdge) { + setMsgRenderIndex(prevPageMsgIndex) + } else if (isTouchBottomEdge) { + setMsgRenderIndex(nextPageMsgIndex) + } + + setHitBottom(isHitBottom) + }) + } + + // api status + const [isApiReady, setIsApiReady] = useState(false) + const [isApiFailed, setIsApiFailed] = useState(false) + const clear = useInterval(async () => { + const bool = await chatStore.checkSessionApiById(session.id) + if (!bool) return + clear() + setIsApiReady(true) + }, 1000) + useCountDown({ + leftTime: 30 * 1000, + onEnd: () => { + if (!isApiReady) setIsApiFailed(true) + clear() + }, + }) + + if (!session || !job) return + + return ( +
+
+ chatStore.onSessionEditParamsShow(job.id)} + /> +
+ + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} +
onChatBodyScroll(e.currentTarget)} + onMouseDown={() => inputRef.current?.blur()} + onTouchStart={() => { + inputRef.current?.blur() + setAutoScroll(false) + }} + > + {!isApiReady && !isApiFailed && {t('ft.online_eval.api.loading')}} + {!isApiReady && isApiFailed && ( + + + {t('ft.online_eval.api.failed')}{' '} + + + )} + {isApiReady && + messages.map((message) => { + const isUser = message.role === 'user' + // const isContext = i < context.length + // const showActions = i > 0 && !(message.preview || message.content.length === 0) && !isContext + // const showTyping = message.preview || message.streaming + // const shouldShowClearContextDivider = i === clearContextIndex - 1 + + return ( + +
+
+
+
{message.content}
+
+
+
+ {/* {shouldShowClearContextDivider && } */} +
+ ) + })} +
+
+ ) +} + +function ChatParamerModal({ useChatStore }: { useChatStore: StoreT }) { + const chatStore = useChatStore() + const [t] = useTranslation() + const [forceKey, forceUpdate] = useReducer((s) => s + 1, 0) + const editingSession = chatStore.getSessionById(chatStore?.editingSessionId as any) + if (!editingSession) return null + + return ( + chatStore.onSessionEditParamsHide()} + closeable + animate + autoFocus + > + {editingSession?.serving?.job?.modelName} + +
+
+ {t('ft.online_eval.parameter.title')} + { + chatStore.onSessionEditParamsReset(editingSession.id) + forceUpdate() + }} + > + {t('ft.online_eval.parameter.reset')} + +
+
+ {editingSession?.serving?.apiSpec?.components?.map( + ({ componentValueSpecFloat, componentValueSpecInt, ...v }) => { + const valueSpec = componentValueSpecFloat ?? componentValueSpecInt + const defValue = editingSession.params?.[v.name] ?? valueSpec?.defaultVal ?? 0 + + if (!valueSpec) return null + + return ( +
+

{v.name}

+ { + chatStore.onSessionEditParams(editingSession.id, { + [v.name]: value[0], + }) + }} + /> +

{defValue}

+
+ ) + } + )} +
+
+
+
+ ) +} + +function ChatGroup({ useStore: useChatStore }: { useStore: StoreT }) { + const chatStore = useChatStore() + const inputRef = useRef(null) + const scroll = useDomsScrollToBottom() + const [userInput, setUserInput] = useState('') + const { submitKey, shouldSubmit } = useSubmitHandler() + const { scrollDomToBottom, setAutoScroll, scrollRefs } = scroll + const sharedChatProps = { inputRef, ...scroll, userInput, setUserInput } + const [t] = useTranslation() + const [inputRows, setInputRows] = useState(2) + const config = useServingConfig() + useDebounceEffect( + () => { + const rows = inputRef.current ? autoGrowTextArea(inputRef.current) : 1 + const _inputRows = Math.min(5, Math.max(1, rows)) + setInputRows(_inputRows) + }, + [userInput], + { + wait: 100, + leading: true, + trailing: true, + } + ) + const onInput = (text: string) => { + setUserInput(text) + } + const doSubmit = (_userInput: string) => { + if (_userInput.trim() === '') return + setUserInput('') + inputRef.current?.focus() + setAutoScroll(true) + chatStore.onUserInput(InferenceType.llm_chat, _userInput) + localStorage.setItem(LAST_INPUT_KEY, userInput) + } + const onInputKeyDown = (e: React.KeyboardEvent) => { + // if ArrowUp and no userInput, fill with last input + if (e.key === 'ArrowUp' && userInput.length <= 0 && !(e.metaKey || e.altKey || e.ctrlKey)) { + setUserInput(localStorage.getItem(LAST_INPUT_KEY) ?? '') + e.preventDefault() + return + } + if (shouldSubmit(e)) { + doSubmit(userInput) + e.preventDefault() + } + } + + function scrollToBottom() { + scrollDomToBottom() + } + + return ( +
+
+ + {chatStore.sessions + .filter((session) => session.show && session.serving?.type === InferenceType.llm_chat) + .map((v, index) => { + if (!v.id) return null + return ( + { + scrollRefs.current[index] = el + }} + /> + ) + })} + +
+
+