mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-31 21:13:49 +08:00
refactor: update chat ui (#13269)
### What problem does this PR solve? Update **Chat** UI: - Align to the design. - Update `<AudioButton>` visualizer logic. - Fix keyboard navigation issue. ### Type of change - [x] Refactoring
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { transformFile2Base64 } from '@/utils/file-util';
|
||||
import { Pencil, Plus, XIcon } from 'lucide-react';
|
||||
import { LucidePencil, LucidePlus, LucideX } from 'lucide-react';
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
forwardRef,
|
||||
@@ -45,7 +45,7 @@ export const AvatarUpload = forwardRef<HTMLInputElement, AvatarUploadProps>(
|
||||
}
|
||||
ev.target.value = '';
|
||||
},
|
||||
[onChange],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleRemove = useCallback(() => {
|
||||
@@ -249,30 +249,36 @@ export const AvatarUpload = forwardRef<HTMLInputElement, AvatarUploadProps>(
|
||||
};
|
||||
}
|
||||
}, 100);
|
||||
}, [handleWheel, containerRef.current]);
|
||||
}, [handleWheel, imageToCrop, isCropModalOpen]);
|
||||
|
||||
return (
|
||||
<div className="flex justify-start items-end space-x-2">
|
||||
<div className="relative group">
|
||||
{!avatarBase64Str ? (
|
||||
<div className="w-[64px] h-[64px] grid place-content-center border border-dashed bg-bg-input rounded-md">
|
||||
<div className="flex flex-col items-center">
|
||||
<Plus />
|
||||
<p>{t('common.upload')}</p>
|
||||
</div>
|
||||
<div
|
||||
className="
|
||||
border border-dashed border-borer-button rounded-md size-16
|
||||
flex flex-col gap-1 justify-center items-center text-sm text-text-secondary transition-colors
|
||||
group-has-[input:focus-visible]:border-accent-primary group-has-[input:focus-visible]:text-text-primary"
|
||||
>
|
||||
<LucidePlus className="size-4" />
|
||||
<span>{t('common.upload')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-[64px] h-[64px] relative grid place-content-center">
|
||||
<Avatar className="w-[64px] h-[64px] rounded-md">
|
||||
<div className="size-16 relative grid place-content-center">
|
||||
<Avatar className="size-16 rounded-md">
|
||||
<AvatarImage className="block" src={avatarBase64Str} alt="" />
|
||||
<AvatarFallback></AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="absolute inset-0 bg-[#000]/20 group-hover:bg-[#000]/60">
|
||||
<Pencil
|
||||
size={20}
|
||||
className="absolute right-2 bottom-0 opacity-50 hidden group-hover:block"
|
||||
/>
|
||||
|
||||
<div
|
||||
className="
|
||||
absolute inset-0 bg-black/50 flex items-center justify-center
|
||||
transition-opacity opacity-0 group-hover:opacity-100 group-has-[input:focus-visible]:opacity-100"
|
||||
>
|
||||
<LucidePencil className="size-5 opacity-75" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleRemove}
|
||||
size="icon"
|
||||
@@ -280,10 +286,11 @@ export const AvatarUpload = forwardRef<HTMLInputElement, AvatarUploadProps>(
|
||||
aria-label="Remove image"
|
||||
type="button"
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
<LucideX className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
placeholder=""
|
||||
type="file"
|
||||
@@ -294,7 +301,7 @@ export const AvatarUpload = forwardRef<HTMLInputElement, AvatarUploadProps>(
|
||||
ref={ref}
|
||||
/>
|
||||
</div>
|
||||
<div className="margin-1 text-text-secondary">
|
||||
<div className="margin-1 text-sm text-text-secondary">
|
||||
{tips ?? t('knowledgeConfiguration.photoTip')}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { AlertDialogOverlay } from '@radix-ui/react-alert-dialog';
|
||||
|
||||
import { DialogProps } from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -55,53 +55,48 @@ export function ConfirmDeleteDialog({
|
||||
defaultOpen={defaultOpen}
|
||||
>
|
||||
{children && <AlertDialogTrigger asChild>{children}</AlertDialogTrigger>}
|
||||
<AlertDialogOverlay
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
|
||||
<AlertDialogContent
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-bg-base "
|
||||
>
|
||||
<AlertDialogContent
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-bg-base "
|
||||
>
|
||||
<AlertDialogHeader className="space-y-5">
|
||||
<AlertDialogTitle>
|
||||
{title ?? t('common.deleteModalTitle')}
|
||||
<AlertDialogCancel
|
||||
onClick={onCancel}
|
||||
className="border-none bg-transparent hover:border-none hover:bg-transparent absolute right-3 top-3 hover:text-text-primary"
|
||||
>
|
||||
<X size={16} />
|
||||
</AlertDialogCancel>
|
||||
</AlertDialogTitle>
|
||||
{content && (
|
||||
<>
|
||||
<Separator className="w-[calc(100%+48px)] -translate-x-6"></Separator>
|
||||
<AlertDialogDescription className="mt-5">
|
||||
<div className="flex flex-col gap-5 text-base mb-10 px-5">
|
||||
<div className="text-text-primary">
|
||||
{content.title || t('common.deleteModalTitle')}
|
||||
</div>
|
||||
{content.node}
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</>
|
||||
)}
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="px-5 flex items-center gap-2">
|
||||
<AlertDialogCancel onClick={onCancel}>
|
||||
{cancelButtonText || t('common.cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-state-error text-text-primary hover:text-text-primary hover:bg-state-error"
|
||||
onClick={onOk}
|
||||
<AlertDialogHeader className="space-y-5">
|
||||
<AlertDialogTitle>
|
||||
{title ?? t('common.deleteModalTitle')}
|
||||
<AlertDialogCancel
|
||||
onClick={onCancel}
|
||||
className="border-none bg-transparent hover:border-none hover:bg-transparent absolute right-3 top-3 hover:text-text-primary"
|
||||
>
|
||||
{okButtonText || t('common.delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogOverlay>
|
||||
<X size={16} />
|
||||
</AlertDialogCancel>
|
||||
</AlertDialogTitle>
|
||||
{content && (
|
||||
<>
|
||||
<Separator className="w-[calc(100%+48px)] -translate-x-6"></Separator>
|
||||
<AlertDialogDescription className="mt-5">
|
||||
<div className="flex flex-col gap-5 text-base mb-10 px-5">
|
||||
<div className="text-text-primary">
|
||||
{content.title || t('common.deleteModalTitle')}
|
||||
</div>
|
||||
{content.node}
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</>
|
||||
)}
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="px-5 flex items-center gap-2">
|
||||
<AlertDialogCancel onClick={onCancel}>
|
||||
{cancelButtonText || t('common.cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-state-error text-text-primary hover:text-text-primary hover:bg-state-error"
|
||||
onClick={onOk}
|
||||
>
|
||||
{okButtonText || t('common.delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { useTranslate } from '@/hooks/common-hooks';
|
||||
import { CheckOutlined, CopyOutlined } from '@ant-design/icons';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { LucideCheck, LucideCopy } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { CopyToClipboard as Clipboard, Props } from 'react-copy-to-clipboard';
|
||||
import { CopyToClipboard as Clipboard } from 'react-copy-to-clipboard';
|
||||
import { Button, ButtonProps } from './ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
|
||||
const CopyToClipboard = ({ text }: Props) => {
|
||||
const CopyToClipboard = ({
|
||||
text,
|
||||
className,
|
||||
...buttonProps
|
||||
}: { text: string } & ButtonProps) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { t } = useTranslate('common');
|
||||
|
||||
@@ -16,12 +22,19 @@ const CopyToClipboard = ({ text }: Props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Clipboard text={text} onCopy={handleCopy}>
|
||||
{copied ? <CheckOutlined /> : <CopyOutlined />}
|
||||
</Clipboard>
|
||||
</TooltipTrigger>
|
||||
<Tooltip open={copied ? true : undefined}>
|
||||
<Clipboard text={text} onCopy={handleCopy}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="transparent"
|
||||
size="icon-sm"
|
||||
{...buttonProps}
|
||||
className={cn(className, copied && '!text-state-success')}
|
||||
>
|
||||
{copied ? <LucideCheck /> : <LucideCopy />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</Clipboard>
|
||||
<TooltipContent>{copied ? t('copied') : t('copy')}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
} from '@/components/file-upload';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { t } from 'i18next';
|
||||
import {
|
||||
Atom,
|
||||
@@ -159,7 +158,7 @@ export function NextMessageInput({
|
||||
onValueChange={setFiles}
|
||||
onUpload={onUpload}
|
||||
onFileReject={onFileReject}
|
||||
className="relative w-full items-center "
|
||||
className="relative w-full items-center"
|
||||
disabled={isUploading || disabled}
|
||||
>
|
||||
<FileUploadDropzone
|
||||
@@ -178,9 +177,14 @@ export function NextMessageInput({
|
||||
</p>
|
||||
</div>
|
||||
</FileUploadDropzone>
|
||||
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="relative flex w-full flex-col gap-2.5 rounded-md border border-input px-3 py-2 outline-none focus-within:ring-1 focus-within:ring-ring/50"
|
||||
className="
|
||||
relative flex w-full flex-col gap-2.5 rounded-md
|
||||
border-0.5 border-border-default bg-bg-card p-2 outline-none
|
||||
has-[textarea:focus]:outline-accent-primary has-[textarea:focus]:outline-1 has-[textarea:focus]:outline-offset-2
|
||||
"
|
||||
>
|
||||
<FileUploadList
|
||||
orientation="horizontal"
|
||||
@@ -205,25 +209,29 @@ export function NextMessageInput({
|
||||
</FileUploadItem>
|
||||
))}
|
||||
</FileUploadList>
|
||||
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={onInputChange}
|
||||
placeholder={t('chat.messagePlaceholder')}
|
||||
className="min-h-10 max-h-40 w-full border-0 bg-transparent p-0 shadow-none focus-visible:ring-0 dark:bg-transparent"
|
||||
className="
|
||||
min-h-10 max-h-40 w-full p-0 overflow-auto
|
||||
!outline-none !border-transparent !bg-transparent !shadow-none !ring-transparent !ring-offset-transparent
|
||||
"
|
||||
disabled={isUploading || disabled || sendLoading}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoSize={{ minRows: 1, maxRows: 8 }}
|
||||
resize={resize}
|
||||
autoSize={{ minRows: 2, maxRows: 8 }}
|
||||
/>
|
||||
<div className={cn('flex items-center justify-between gap-1.5')}>
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{showUploadIcon && (
|
||||
<FileUploadTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7 rounded-sm"
|
||||
size="icon-xs"
|
||||
variant="transparent"
|
||||
className="rounded-sm border-0"
|
||||
disabled={isUploading || sendLoading}
|
||||
>
|
||||
<Paperclip className="size-3.5" />
|
||||
@@ -231,40 +239,35 @@ export function NextMessageInput({
|
||||
</Button>
|
||||
</FileUploadTrigger>
|
||||
)}
|
||||
|
||||
{showReasoning && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'rounded-sm h-7 focus-visible:bg-none! hover:bg-none!',
|
||||
{
|
||||
'bg-accent-primary text-white': enableThinking,
|
||||
},
|
||||
)}
|
||||
size="sm"
|
||||
variant={enableThinking ? 'accent' : 'transparent'}
|
||||
className="border-0 h-7 text-sm"
|
||||
onClick={handleThinkingToggle}
|
||||
>
|
||||
<Atom />
|
||||
<span>Thinking</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showInternet && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'rounded-sm h-7 focus-visible:bg-none! hover:bg-none!',
|
||||
{
|
||||
'bg-accent-primary text-white': enableInternet,
|
||||
},
|
||||
)}
|
||||
variant={enableInternet ? 'accent' : 'transparent'}
|
||||
size="icon-xs"
|
||||
className="border-0"
|
||||
onClick={handleInternetToggle}
|
||||
>
|
||||
<Globe />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sendLoading ? (
|
||||
<Button onClick={stopOutputMessage} className="size-5 rounded-sm">
|
||||
<Button onClick={stopOutputMessage} size="icon-xs">
|
||||
<CircleStop />
|
||||
</Button>
|
||||
) : (
|
||||
@@ -274,8 +277,9 @@ export function NextMessageInput({
|
||||
setAudioInputValue(value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="size-5 rounded-sm"
|
||||
size="icon-xs"
|
||||
disabled={
|
||||
sendDisabled || isUploading || sendLoading || !value.trim()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { PromptIcon } from '@/assets/icon/next-icon';
|
||||
import CopyToClipboard from '@/components/copy-to-clipboard';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -8,18 +7,20 @@ import {
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useSetModalState } from '@/hooks/common-hooks';
|
||||
import { IRemoveMessageById } from '@/hooks/logic-hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
DeleteOutlined,
|
||||
DislikeOutlined,
|
||||
LikeOutlined,
|
||||
PauseCircleOutlined,
|
||||
SoundOutlined,
|
||||
SyncOutlined,
|
||||
} from '@ant-design/icons';
|
||||
LucidePauseCircle,
|
||||
LucideRefreshCw,
|
||||
LucideThumbsDown,
|
||||
LucideThumbsUp,
|
||||
LucideTrash2,
|
||||
LucideVolume2,
|
||||
} from 'lucide-react';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FeedbackDialog from '../feedback-dialog';
|
||||
import { PromptDialog } from '../prompt-dialog';
|
||||
import { Button } from '../ui/button';
|
||||
import { useRemoveMessage, useSendFeedback, useSpeech } from './hooks';
|
||||
|
||||
interface IProps {
|
||||
@@ -55,44 +56,57 @@ export const AssistantGroupButton = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="space-x-1"
|
||||
>
|
||||
<ToggleGroupItem value="a">
|
||||
<CopyToClipboard text={content}></CopyToClipboard>
|
||||
</ToggleGroupItem>
|
||||
<div className="flex gap-1" role="toolbar">
|
||||
<CopyToClipboard text={content} className="border-0" size="icon-xs" />
|
||||
|
||||
{showLoudspeaker && (
|
||||
<ToggleGroupItem value="b" onClick={handleRead}>
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <SoundOutlined />}
|
||||
</span>
|
||||
<Button
|
||||
variant="transparent"
|
||||
size="icon-xs"
|
||||
className="border-0"
|
||||
onClick={handleRead}
|
||||
>
|
||||
<span>
|
||||
{isPlaying ? <LucidePauseCircle /> : <LucideVolume2 />}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('chat.read')}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<audio src="" ref={ref}></audio>
|
||||
</ToggleGroupItem>
|
||||
</>
|
||||
)}
|
||||
{showLikeButton && (
|
||||
<>
|
||||
<ToggleGroupItem value="c" onClick={handleLike}>
|
||||
<LikeOutlined />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="d" onClick={showModal}>
|
||||
<DislikeOutlined />
|
||||
</ToggleGroupItem>
|
||||
<Button
|
||||
variant="transparent"
|
||||
size="icon-xs"
|
||||
className="border-0"
|
||||
onClick={handleLike}
|
||||
>
|
||||
<LucideThumbsUp />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="transparent"
|
||||
size="icon-xs"
|
||||
className="border-0"
|
||||
onClick={showModal}
|
||||
>
|
||||
<LucideThumbsDown />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{prompt && (
|
||||
<ToggleGroupItem value="e" onClick={showPromptModal}>
|
||||
<Button onClick={showPromptModal}>
|
||||
<PromptIcon style={{ fontSize: '16px' }} />
|
||||
</ToggleGroupItem>
|
||||
</Button>
|
||||
)}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
{visible && (
|
||||
<FeedbackDialog
|
||||
visible={visible}
|
||||
@@ -133,39 +147,41 @@ export const UserGroupButton = ({
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="space-x-1"
|
||||
>
|
||||
<ToggleGroupItem value="a">
|
||||
<CopyToClipboard text={content}></CopyToClipboard>
|
||||
</ToggleGroupItem>
|
||||
<div className="flex gap-1">
|
||||
<CopyToClipboard text={content} className="border-0" size="icon-xs" />
|
||||
|
||||
{regenerateMessage && (
|
||||
<ToggleGroupItem
|
||||
value="b"
|
||||
onClick={regenerateMessage}
|
||||
disabled={sendLoading}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SyncOutlined spin={sendLoading} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('chat.regenerate')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</ToggleGroupItem>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="transparent"
|
||||
size="icon-xs"
|
||||
className="border-0"
|
||||
onClick={regenerateMessage}
|
||||
disabled={sendLoading}
|
||||
>
|
||||
<LucideRefreshCw className={cn(sendLoading && 'animate-spin')} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('chat.regenerate')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{removeMessageById && (
|
||||
<ToggleGroupItem value="c" onClick={onRemoveMessage} disabled={loading}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DeleteOutlined spin={loading} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('common.delete')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</ToggleGroupItem>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="transparent"
|
||||
size="icon-xs"
|
||||
className="border-0"
|
||||
onClick={onRemoveMessage}
|
||||
disabled={loading}
|
||||
>
|
||||
<LucideTrash2 />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('common.delete')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
.messageTextDark {
|
||||
.chunkText();
|
||||
.messageTextBase();
|
||||
padding: 0;
|
||||
word-break: break-word;
|
||||
:global(section.think) {
|
||||
color: rgb(166, 166, 166);
|
||||
|
||||
@@ -4,14 +4,15 @@ import React from 'react';
|
||||
import { Button, ButtonProps } from './ui/button';
|
||||
|
||||
export const MoreButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, size, ...props }, ref) => {
|
||||
function MoreButton({ className, size, ...props }, ref) {
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size={size || 'icon'}
|
||||
className={cn(
|
||||
'invisible group-hover:visible size-3.5 bg-transparent group-hover:bg-transparent',
|
||||
'invisible size-3.5 bg-transparent group-hover:bg-transparent',
|
||||
'group-focus-within:visible group-hover:visible aria-expanded:visible',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -5,11 +5,13 @@ import { Authorization } from '@/constants/authorization';
|
||||
import { cn } from '@/lib/utils';
|
||||
import api from '@/utils/api';
|
||||
import { getAuthorization } from '@/utils/authorization-util';
|
||||
import { chain, sum } from 'lodash';
|
||||
import { Loader2, Mic, Square } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useIsDarkTheme } from '../theme-provider';
|
||||
import { Input } from './input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||
|
||||
const VoiceVisualizer = ({ isRecording }: { isRecording: boolean }) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
@@ -18,7 +20,79 @@ const VoiceVisualizer = ({ isRecording }: { isRecording: boolean }) => {
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const isDark = useIsDarkTheme();
|
||||
|
||||
const startVisualization = async () => {
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const analyser = analyserRef.current;
|
||||
if (!analyser) return;
|
||||
|
||||
// Set canvas dimensions
|
||||
const width = canvas.clientWidth * window.devicePixelRatio;
|
||||
const height = canvas.clientHeight * window.devicePixelRatio;
|
||||
const centerY = height / 2;
|
||||
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
}
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Get frequency data
|
||||
const bufferLength = analyser.frequencyBinCount;
|
||||
const dataArray = new Uint8Array(bufferLength);
|
||||
|
||||
/**
|
||||
* The frequencies are spread linearly from 0Hz to 1/2 of the sample rate
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode/getByteFrequencyData
|
||||
*/
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
|
||||
const desiredBarCount = 6;
|
||||
const freqSpanPerPole =
|
||||
analyser.context.sampleRate / 2 / analyser.frequencyBinCount;
|
||||
const freqMinIndex = Math.max(1, Math.floor(100 / freqSpanPerPole));
|
||||
const freqMaxIndex =
|
||||
Math.max(freqMinIndex, Math.ceil(3500 / freqSpanPerPole)) + 1;
|
||||
|
||||
const freqData = dataArray.slice(
|
||||
freqMinIndex,
|
||||
Math.max(freqMinIndex + desiredBarCount, freqMaxIndex),
|
||||
);
|
||||
|
||||
const reducedFreqData = chain(freqData)
|
||||
.chunk(Math.floor(freqData.length / desiredBarCount))
|
||||
.take(desiredBarCount)
|
||||
.map(sum)
|
||||
.value();
|
||||
|
||||
// Draw waveform
|
||||
const barGap = 1 * window.devicePixelRatio;
|
||||
const barWidth = (width - barGap * (desiredBarCount - 1)) / desiredBarCount;
|
||||
|
||||
// Create gradient
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
||||
gradient.addColorStop(0, '#3ba05c'); // Blue
|
||||
gradient.addColorStop(1, '#3ba05c'); // Light blue
|
||||
// gradient.addColorStop(0, isDark ? '#fff' : '#000'); // Blue
|
||||
// gradient.addColorStop(1, isDark ? '#eee' : '#eee'); // Light blue
|
||||
|
||||
for (let i = 0, x = 0; i < desiredBarCount; i++, x += barWidth + barGap) {
|
||||
const barHeight = (reducedFreqData[i] / 255) * centerY;
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(x, centerY - barHeight, barWidth, barHeight * 2);
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(draw);
|
||||
}, []);
|
||||
|
||||
const startVisualization = useCallback(async () => {
|
||||
try {
|
||||
// Check if the browser supports getUserMedia
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
@@ -30,13 +104,14 @@ const VoiceVisualizer = ({ isRecording }: { isRecording: boolean }) => {
|
||||
streamRef.current = stream;
|
||||
|
||||
// Create audio context and analyzer
|
||||
const audioContext = new (window.AudioContext ||
|
||||
(window as any).webkitAudioContext)();
|
||||
const audioContext = new (
|
||||
window.AudioContext || (window as any).webkitAudioContext
|
||||
)();
|
||||
audioContextRef.current = audioContext;
|
||||
|
||||
const analyser = audioContext.createAnalyser();
|
||||
analyserRef.current = analyser;
|
||||
analyser.fftSize = 32;
|
||||
analyser.fftSize = 256;
|
||||
|
||||
// Connect audio nodes
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
@@ -50,9 +125,9 @@ const VoiceVisualizer = ({ isRecording }: { isRecording: boolean }) => {
|
||||
error,
|
||||
);
|
||||
}
|
||||
};
|
||||
}, [draw]);
|
||||
|
||||
const stopVisualization = () => {
|
||||
const stopVisualization = useCallback(() => {
|
||||
// Stop animation frame
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
@@ -76,7 +151,8 @@ const VoiceVisualizer = ({ isRecording }: { isRecording: boolean }) => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRecording) {
|
||||
startVisualization();
|
||||
@@ -87,68 +163,9 @@ const VoiceVisualizer = ({ isRecording }: { isRecording: boolean }) => {
|
||||
return () => {
|
||||
stopVisualization();
|
||||
};
|
||||
}, [isRecording]);
|
||||
const draw = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
}, [isRecording, startVisualization, stopVisualization]);
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const analyser = analyserRef.current;
|
||||
if (!analyser) return;
|
||||
|
||||
// Set canvas dimensions
|
||||
const width = canvas.clientWidth;
|
||||
const height = canvas.clientHeight;
|
||||
const centerY = height / 2;
|
||||
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
}
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Get frequency data
|
||||
const bufferLength = analyser.frequencyBinCount;
|
||||
const dataArray = new Uint8Array(bufferLength);
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
|
||||
// Draw waveform
|
||||
const barWidth = (width / bufferLength) * 1.5;
|
||||
let x = 0;
|
||||
|
||||
for (let i = 0; i < bufferLength; i = i + 2) {
|
||||
const barHeight = (dataArray[i] / 255) * centerY;
|
||||
|
||||
// Create gradient
|
||||
const gradient = ctx.createLinearGradient(
|
||||
0,
|
||||
centerY - barHeight,
|
||||
0,
|
||||
centerY + barHeight,
|
||||
);
|
||||
gradient.addColorStop(0, '#3ba05c'); // Blue
|
||||
gradient.addColorStop(1, '#3ba05c'); // Light blue
|
||||
// gradient.addColorStop(0, isDark ? '#fff' : '#000'); // Blue
|
||||
// gradient.addColorStop(1, isDark ? '#eee' : '#eee'); // Light blue
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(x, centerY - barHeight, barWidth, barHeight * 2);
|
||||
|
||||
x += barWidth + 2;
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-6 bg-transparent flex items-center justify-center overflow-hidden ">
|
||||
<canvas ref={canvasRef} className="w-full h-full" />
|
||||
</div>
|
||||
);
|
||||
return <canvas ref={canvasRef} className="block size-full" />;
|
||||
};
|
||||
|
||||
const VoiceInputBox = ({
|
||||
@@ -363,21 +380,22 @@ export const AudioButton = ({
|
||||
|
||||
<div className=" relative w-6 h-6 flex items-center justify-center">
|
||||
{isRecording && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 w-full h-6 rounded-full overflow-hidden flex items-center justify-center p-1',
|
||||
{ 'bg-state-success-5': isRecording },
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-0 size-full overflow-hidden flex items-center justify-center p-1">
|
||||
<VoiceVisualizer isRecording={isRecording} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRecording && (
|
||||
<div className="absolute inset-0 rounded-full border-2 border-state-success animate-ping opacity-75"></div>
|
||||
<div
|
||||
className="
|
||||
absolute inset-0 rounded-full border-2 border-state-success
|
||||
animate-ping duration-[4s] opacity-75 pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
variant="transparent"
|
||||
size="icon-xs"
|
||||
// onMouseDown={() => {
|
||||
// startRecording();
|
||||
// }}
|
||||
@@ -391,11 +409,11 @@ export const AudioButton = ({
|
||||
startRecording();
|
||||
}
|
||||
}}
|
||||
className={`w-6 h-6 p-2 rounded-md border-none bg-transparent hover:bg-state-success-5 ${
|
||||
isRecording
|
||||
? 'animate-pulse bg-state-success-5 text-state-success'
|
||||
: ''
|
||||
}`}
|
||||
className={cn(
|
||||
'border-0 p-2 rounded-md border-none bg-transparent hover:bg-state-success-5',
|
||||
isRecording &&
|
||||
'animate-pulse !bg-state-success/20 text-state-success rounded-full',
|
||||
)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? (
|
||||
|
||||
@@ -53,7 +53,10 @@ const BreadcrumbLink = React.forwardRef<
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn('transition-colors hover:text-foreground', className)}
|
||||
className={cn(
|
||||
'transition-colors hover:text-foreground focus-visible:text-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
83
web/src/components/ui/button-group.tsx
Normal file
83
web/src/components/ui/button-group.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonGroupVariants = cva(
|
||||
"flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
'[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none',
|
||||
vertical:
|
||||
'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: 'horizontal',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function ButtonGroup({
|
||||
className,
|
||||
orientation,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof buttonGroupVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
data-orientation={orientation}
|
||||
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonGroupText({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'div';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"bg-muted shadow-xs flex items-center gap-2 rounded-md border px-4 text-sm font-medium [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonGroupSeparator({
|
||||
className,
|
||||
orientation = 'vertical',
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="button-group-separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
ButtonGroup,
|
||||
ButtonGroupSeparator,
|
||||
ButtonGroupText,
|
||||
buttonGroupVariants,
|
||||
};
|
||||
@@ -36,7 +36,7 @@ const buttonVariants = cva(
|
||||
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
icon: 'bg-colors-background-inverse-standard text-foreground hover:bg-colors-background-inverse-standard/80',
|
||||
dashed: 'border border-dashed border-input hover:bg-accent',
|
||||
dashed: 'border border-dashed border-border-button hover:bg-accent',
|
||||
|
||||
transparent: `
|
||||
text-text-secondary bg-transparent border-0.5 border-border-button
|
||||
@@ -49,10 +49,16 @@ const buttonVariants = cva(
|
||||
hover:bg-state-error/10 focus-visible:bg-state-error/10
|
||||
`,
|
||||
|
||||
accent: `
|
||||
bg-accent-primary text-white
|
||||
hover:bg-accent-primary/90 focus-visible:bg-accent-primary/90
|
||||
`,
|
||||
|
||||
highlighted: `
|
||||
bg-text-primary text-bg-base border-b-4 border-b-accent-primary
|
||||
hover:bg-text-primary/90 focus-visible:bg-text-primary/90
|
||||
`,
|
||||
|
||||
delete: `
|
||||
text-text-secondary
|
||||
hover:bg-state-error-5 hover:text-state-error
|
||||
@@ -65,6 +71,8 @@ const buttonVariants = cva(
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
auto: 'h-full px-1',
|
||||
'icon-sm': 'size-8',
|
||||
'icon-xs': 'size-7',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -75,7 +83,8 @@ const buttonVariants = cva(
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
extends
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
loading?: boolean;
|
||||
@@ -126,7 +135,7 @@ ButtonLoading.displayName = 'ButtonLoading';
|
||||
export { Button, buttonVariants };
|
||||
|
||||
export const BlockButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ children, className, ...props }, ref) => {
|
||||
function BlockButton({ children, className, ...props }, ref) {
|
||||
return (
|
||||
<Button
|
||||
variant={'outline'}
|
||||
|
||||
@@ -5,8 +5,9 @@ import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DualRangeSliderProps
|
||||
extends React.ComponentProps<typeof SliderPrimitive.Root> {
|
||||
interface DualRangeSliderProps extends React.ComponentProps<
|
||||
typeof SliderPrimitive.Root
|
||||
> {
|
||||
labelPosition?: 'top' | 'bottom';
|
||||
label?: (value: number | undefined) => React.ReactNode;
|
||||
}
|
||||
@@ -33,7 +34,12 @@ const DualRangeSlider = React.forwardRef<
|
||||
</SliderPrimitive.Track>
|
||||
{initialValue.map((value, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<SliderPrimitive.Thumb className="relative block h-2.5 w-2.5 rounded-full border-2 border-accent-primary bg-white ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 cursor-pointer">
|
||||
<SliderPrimitive.Thumb
|
||||
className="
|
||||
relative block h-2.5 w-2.5 rounded-full border-2 border-accent-primary bg-white ring-offset-background transition-colors
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground focus-visible:ring-offset-2
|
||||
disabled:pointer-events-none disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{label && (
|
||||
<span
|
||||
className={cn(
|
||||
|
||||
@@ -20,7 +20,13 @@ const Slider = React.forwardRef<
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-colors-background-inverse-strong">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-colors-text-core-standard ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||
|
||||
<SliderPrimitive.Thumb
|
||||
className="
|
||||
block h-5 w-5 rounded-full border-2 border-primary bg-colors-text-core-standard transition-colors ring-offset-background
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground focus-visible:ring-offset-2
|
||||
disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
@@ -33,16 +39,18 @@ type SliderProps = Omit<
|
||||
const FormSlider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
SliderProps
|
||||
>(({ onChange, value, ...props }, ref) => (
|
||||
<Slider
|
||||
ref={ref}
|
||||
{...props}
|
||||
value={[value]}
|
||||
onValueChange={(vals) => {
|
||||
onChange(vals[0]);
|
||||
}}
|
||||
></Slider>
|
||||
));
|
||||
>(function FormSlider({ onChange, value, ...props }, ref) {
|
||||
return (
|
||||
<Slider
|
||||
ref={ref}
|
||||
{...props}
|
||||
value={[value]}
|
||||
onValueChange={(vals) => {
|
||||
onChange(vals[0]);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user