Skip to content

Commit

Permalink
style(frontend): optimize message view (#293)
Browse files Browse the repository at this point in the history
close #287 
close #288 
close #289 
close #291
  • Loading branch information
634750802 authored Sep 19, 2024
1 parent 2adfb27 commit 92ff0bb
Show file tree
Hide file tree
Showing 18 changed files with 133 additions and 38 deletions.
5 changes: 5 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,11 @@ jobs:
- name: Install Playwright Browsers
run: npx playwright install --with-deps

- name: Fetch Images
run: ./prepare-test.sh
env:
E2E_DOCKER_TAG: sha-${{ github.sha }}-dev

- name: Run tests
run: ./start-test.sh
env:
Expand Down
5 changes: 5 additions & 0 deletions e2e/prepare-test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/bin/bash

set -e

docker compose pull frontend background backend tidb redis static-web-server
2 changes: 1 addition & 1 deletion e2e/tests/chat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ test.describe('Chat', () => {

// https://playwright.dev/docs/events#waiting-for-event
const chatRequestPromise = getChatRequestPromise(page, baseURL);
const trigger = page.locator('button', { has: page.locator('svg.lucide-arrow-right') });
const trigger = page.locator('button', { has: page.locator('svg.lucide-arrow-up') });
await trigger.click();

await expect(trigger).toBeDisabled();
Expand Down
2 changes: 1 addition & 1 deletion e2e/utils/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export async function testNewChat (page: Page, chatRequest: Request, validatePag
expect(chatResponse.ok()).toBe(true);

// Feedback button indicates chat ends.
await page.locator('button', { has: page.locator('svg.lucide-message-square-plus') }).waitFor({ state: 'visible' });
await page.locator('button', { has: page.locator('svg.lucide-thumbs-up') }).waitFor({ state: 'visible' });

return await chatResponse.text();
});
Expand Down
4 changes: 4 additions & 0 deletions frontend/app/src/components/chat/chat-hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export function useChats () {
export interface ChatMessageGroup {
user: ChatMessageController;
assistant: ChatMessageController | undefined;
hasFirstAssistantMessage: boolean;
}

export function useChatController (
Expand Down Expand Up @@ -201,6 +202,7 @@ export function useChatMessageGroups (controllers: ChatMessageController[]) {
function collectMessageGroups (messageControllers: ChatMessageController[]) {
const groups: ChatMessageGroup[] = [];

let hasAssistant: boolean = false;
let user: ChatMessageController | undefined;

for (let messageController of messageControllers) {
Expand All @@ -213,7 +215,9 @@ function collectMessageGroups (messageControllers: ChatMessageController[]) {
groups.push({
user,
assistant: messageController,
hasFirstAssistantMessage: !hasAssistant,
});
hasAssistant = true;
} else {
console.warn('No matched user message, drop assistant message', messageController.message.id);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ function ConversationMessageGroup ({ group }: { group: ChatMessageGroup }) {
</MessageSection>

<MessageSection className="space-y-2" message={group.assistant}>
<MessageAnswer message={group.assistant} />
<MessageAnswer message={group.assistant} showBetaAlert={group.hasFirstAssistantMessage} />
{group.assistant && <MessageAutoScroll message={group.assistant} />}
</MessageSection>

Expand Down
2 changes: 1 addition & 1 deletion frontend/app/src/components/chat/conversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export function Conversation ({ open, chat, chatId, history, placeholder, preven
<ConversationMessageGroups groups={groups} />
<div className="h-24"></div>
</div>
{size && open && <form className={cn('block h-max p-4 fixed bottom-0', preventShiftMessageInput && 'absolute pb-0')} onSubmit={submitWithReCaptcha} style={{ left: preventShiftMessageInput ? 0 : size.x, width: size.width }}>
{size && open && <form className={cn('block h-max p-4 fixed bottom-0', preventShiftMessageInput && 'absolute pb-0')} onSubmit={submitWithReCaptcha} style={{ left: (preventShiftMessageInput ? 0 : size.x) + 16, width: size.width - 32 }}>
<MessageInput inputRef={setInputElement} className="w-full transition-all" disabled={disabled} actionDisabled={actionDisabled} inputProps={{ value: input, onChange: handleInputChange, disabled }} />
</form>}
</ChatControllerProvider>
Expand Down
4 changes: 3 additions & 1 deletion frontend/app/src/components/chat/message-answer.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { useChatMessageField, useChatMessageStreamContainsState } from '@/components/chat/chat-hooks';
import type { ChatMessageController } from '@/components/chat/chat-message-controller';
import { AppChatStreamState } from '@/components/chat/chat-stream-state';
import { MessageBetaAlert } from '@/components/chat/message-beta-alert';
import { MessageContent } from '@/components/chat/message-content';

export function MessageAnswer ({ message }: { message: ChatMessageController | undefined }) {
export function MessageAnswer ({ message, showBetaAlert }: { message: ChatMessageController | undefined, showBetaAlert?: boolean }) {
const content = useChatMessageField(message, 'content');
const shouldShow = useChatMessageStreamContainsState(message, AppChatStreamState.GENERATE_ANSWER);

Expand All @@ -26,6 +27,7 @@ export function MessageAnswer ({ message }: { message: ChatMessageController | u
</svg>
Answer
</div>
{showBetaAlert && <MessageBetaAlert />}
<MessageContent message={message} />
</>
);
Expand Down
16 changes: 16 additions & 0 deletions frontend/app/src/components/chat/message-beta-alert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { FlaskConicalIcon } from 'lucide-react';

export function MessageBetaAlert () {
return (
<Alert variant="info" className='my-2'>
<FlaskConicalIcon />
<AlertTitle>
This chatbot is in Beta.
</AlertTitle>
<AlertDescription>
All generated information should be verified prior to use.
</AlertDescription>
</Alert>
);
}
18 changes: 11 additions & 7 deletions frontend/app/src/components/chat/message-feedback.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
import type { FeedbackParams } from '@/api/chats';
import { usePortalContainer } from '@/components/portal-provider';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { Loader2Icon, ThumbsDownIcon, ThumbsUpIcon } from 'lucide-react';
import { type ReactElement, useEffect, useState } from 'react';
import { type ReactNode, useEffect, useState } from 'react';

export function MessageFeedback ({ initial, onFeedback, children }: { initial?: FeedbackParams, onFeedback: (action: 'like' | 'dislike', comment: string) => Promise<void>, children: ReactElement }) {
export function MessageFeedback ({ initial, onFeedback, defaultAction, children }: { initial?: FeedbackParams, defaultAction?: 'like' | 'dislike', onFeedback: (action: 'like' | 'dislike', comment: string) => Promise<void>, children: ReactNode }) {
const [open, setOpen] = useState(false);
const [action, setAction] = useState<'like' | 'dislike'>(initial?.feedback_type ?? 'like');
const [action, setAction] = useState<'like' | 'dislike'>(initial?.feedback_type ?? defaultAction ?? 'like');
// const [detail, setDetail] = useState<Record<string, 'like' | 'dislike'>>(() => (initial ?? {}));
const [comment, setComment] = useState(initial?.comment ?? '');
const [running, setRunning] = useState(false);
const [deleting, setDeleting] = useState(false);

useEffect(() => {
if (defaultAction && !initial) {
setAction(defaultAction);
}
}, [defaultAction, initial]);

useEffect(() => {
if (initial) {
setAction(initial.feedback_type);
Expand All @@ -30,9 +36,7 @@ export function MessageFeedback ({ initial, onFeedback, children }: { initial?:

return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{children}
</DialogTrigger>
{children}
<DialogContent container={container} className="space-y-4">
<DialogHeader>
<DialogTitle>
Expand Down
6 changes: 3 additions & 3 deletions frontend/app/src/components/chat/message-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { cn } from '@/lib/utils';
import isHotkey from 'is-hotkey';
import { ArrowRightIcon } from 'lucide-react';
import { type ChangeEvent, type Ref, type RefObject, useCallback, useRef, useState } from 'react';
import { ArrowUpIcon } from 'lucide-react';
import { type ChangeEvent, type Ref, useCallback, useRef, useState } from 'react';
import TextareaAutosize, { type TextareaAutosizeProps } from 'react-textarea-autosize';
import useSWR from 'swr';

Expand Down Expand Up @@ -75,7 +75,7 @@ export function MessageInput ({
</SelectContent>
</Select>}
<Button size="icon" className="rounded-full flex-shrink-0 w-8 h-8 p-2" disabled={actionDisabled || disabled} ref={buttonRef}>
<ArrowRightIcon className="w-full h-full" />
<ArrowUpIcon className="w-full h-full" />
</Button>
</div>
);
Expand Down
81 changes: 64 additions & 17 deletions frontend/app/src/components/chat/message-operations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import { useChatMessageField, useChatMessageStreamState, useCurrentChatControlle
import { ChatMessageController } from '@/components/chat/chat-message-controller';
import { MessageFeedback } from '@/components/chat/message-feedback';
import { useMessageFeedback } from '@/components/chat/use-message-feedback';
import { usePortalContainer } from '@/components/portal-provider';
import { Button } from '@/components/ui/button';
import { TooltipProvider } from '@/components/ui/tooltip';
import { DialogTrigger } from '@/components/ui/dialog';
import { Tooltip, TooltipContent, TooltipPortal, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import copy from 'copy-to-clipboard';
import { ClipboardCheckIcon, ClipboardIcon, MessageSquareHeartIcon, MessageSquarePlusIcon } from 'lucide-react';
import { CopyCheckIcon, CopyIcon, ThumbsDownIcon, ThumbsUpIcon } from 'lucide-react';
import { useState } from 'react';

export function MessageOperations ({ message }: { message: ChatMessageController }) {
Expand All @@ -15,6 +17,8 @@ export function MessageOperations ({ message }: { message: ChatMessageController
const streamState = useChatMessageStreamState(message);
const { feedbackData, feedback: callFeedback, disabled } = useMessageFeedback(message.id, !!traceUrl);
const [copied, setCopied] = useState(false);
const [clicked, setClicked] = useState<'like' | 'dislike'>('like');
const container = usePortalContainer();

if (streamState) {
return null;
Expand All @@ -35,25 +39,68 @@ export function MessageOperations ({ message }: { message: ChatMessageController

<MessageFeedback
initial={feedbackData}
defaultAction={clicked}
onFeedback={async (action, comment) => callFeedback(action, comment)}
>
<Button size="icon" variant="ghost" className="ml-auto rounded-full w-7 h-7" disabled={disabled}>
{feedbackData ? <MessageSquareHeartIcon className="w-4 h-4 text-green-500" /> : <MessageSquarePlusIcon className="w-4 h-4" />}
</Button>
{feedbackData
? (<DialogTrigger asChild>
<Button size="icon" variant="ghost" className="ml-auto rounded-full w-7 h-7" disabled={disabled}>
{feedbackData.feedback_type === 'like' ? <ThumbsUpIcon className="w-4 h-4 text-green-500" /> : <ThumbsDownIcon className="w-4 h-4 text-red-500" />}
</Button>
</DialogTrigger>)
: (<>
<Tooltip>
<DialogTrigger asChild>
<TooltipTrigger asChild>
<Button size="icon" variant="ghost" className="ml-auto rounded-full w-7 h-7" disabled={disabled} onClick={() => { setClicked('like'); }}>
<ThumbsUpIcon className="w-4 h-4" />
</Button>
</TooltipTrigger>
</DialogTrigger>
<TooltipPortal container={container}>
<TooltipContent>
I like this answer :)
</TooltipContent>
</TooltipPortal>
</Tooltip>
<Tooltip>
<DialogTrigger asChild>
<TooltipTrigger asChild>
<Button size="icon" variant="ghost" className="rounded-full w-7 h-7" disabled={disabled} onClick={() => { setClicked('dislike'); }}>
<ThumbsDownIcon className="w-4 h-4" />
</Button>
</TooltipTrigger>
</DialogTrigger>
<TooltipPortal container={container}>
<TooltipContent>
I dislike this answer :(
</TooltipContent>
</TooltipPortal>
</Tooltip>
</>)}
</MessageFeedback>

<Button
size="icon"
variant="ghost"
className={cn('rounded-full w-7 h-7 transition-colors', copied && '!text-green-500 hover:bg-green-500/10')}
onClick={() => {
setCopied(copy(message.content));
}}
>
{copied
? <ClipboardCheckIcon className="w-4 h-4" />
: <ClipboardIcon className="w-4 h-4" />}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className={cn('rounded-full w-7 h-7 transition-colors', copied && '!text-green-500 hover:bg-green-500/10')}
onClick={() => {
setCopied(copy(message.content));
}}
>
{copied
? <CopyCheckIcon className="w-4 h-4" />
: <CopyIcon className="w-4 h-4" />}
</Button>
</TooltipTrigger>
<TooltipPortal container={container}>
<TooltipContent>
{copied ? 'Copied!' : 'Copy'}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
</TooltipProvider>
);
Expand Down
2 changes: 2 additions & 0 deletions frontend/app/src/components/ui/alert.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ const alertVariants = cva(
"border-yellow-600/50 text-yellow-600 [&>svg]:text-yellow-600 dark:text-yellow-400 dark:border-yellow-400 dark:[&>svg]:text-yellow-400",
success:
"border-green-600/50 text-green-600 [&>svg]:text-green-600 dark:text-green-400 dark:border-green-400 dark:[&>svg]:text-green-400",
info:
"border-sky-600/50 text-sky-600 [&>svg]:text-sky-600 dark:text-sky-400 dark:border-sky-400 dark:[&>svg]:text-sky-400",
},
},
defaultVariants: {
Expand Down
4 changes: 3 additions & 1 deletion frontend/app/src/components/ui/tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const Tooltip = TooltipPrimitive.Root

const TooltipTrigger = TooltipPrimitive.Trigger

const TooltipPortal = TooltipPrimitive.Portal

const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
Expand All @@ -27,4 +29,4 @@ const TooltipContent = React.forwardRef<
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName

export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
export { Tooltip, TooltipTrigger, TooltipPortal, TooltipContent, TooltipProvider }
6 changes: 3 additions & 3 deletions frontend/app/src/components/use-size.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { useEffect, useRef, useState } from 'react';
import { useLayoutEffect, useRef, useState } from 'react';

export function useSize () {
const [size, setSize] = useState<DOMRectReadOnly | undefined>(undefined);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
useLayoutEffect(() => {
const el = ref.current;
if (el) {
const ro = new ResizeObserver(() => {
Expand All @@ -21,5 +21,5 @@ export function useSize () {

return {
ref, size,
}
};
}
1 change: 1 addition & 0 deletions frontend/packages/widget-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7",
"lucide-react": "^0.400.0",
"postcss": "^8",
"rollup-plugin-visualizer": "^5.12.0",
"sass": "^1.77.6",
Expand Down
8 changes: 6 additions & 2 deletions frontend/packages/widget-react/src/Widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import { useGtagFn } from '@/components/gtag-provider';
import { PortalProvider } from '@/components/portal-provider';
import { BootstrapStatusProvider } from '@/components/system/BootstrapStatusProvider';
import { Button } from '@/components/ui/button';
import { Dialog, DialogDescription, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Dialog, DialogClose, DialogDescription, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { type ExperimentalFeatures, ExperimentalFeaturesProvider } from '@/experimental/experimental-features-provider';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { XIcon } from 'lucide-react'
import './Widget.css';

export interface WidgetProps {
Expand Down Expand Up @@ -147,7 +148,10 @@ export const Widget = forwardRef<WidgetInstance, WidgetProps>(({ container, trig
<DialogOverlay />
<DialogPrimitive.Content
className="fixed left-[50%] top-[50%] z-50 grid translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg w-[calc(100%-32px)] lg:w-[50vw]">
<DialogHeader>
<DialogHeader className='relative'>
<DialogClose className='absolute right-0 top-0 transition-opacity opacity-70 hover:opacity-100'>
<XIcon className='size-4' />
</DialogClose>
<DialogTitle className="flex items-center gap-4">
<img className="h-8" src={icon} alt="logo" height={32} />
<span className="w-[1px] h-full py-2">
Expand Down
3 changes: 3 additions & 0 deletions frontend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 92ff0bb

Please sign in to comment.