mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-29 12:09:31 +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:
@@ -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