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

style(frontend): optimize message view #293

Merged
merged 7 commits into from
Sep 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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.

Loading