Skip to content
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
107 changes: 80 additions & 27 deletions src/components/FeedbackButtons.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { useState } from 'react'
import { LangfuseWeb } from 'langfuse'
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'
import { FeedbackPopoverContent } from './FeedbackPopoverContent'

const langfuse = import.meta.env.VITE_LANGFUSE_PUBLIC_KEY
? new LangfuseWeb({
publicKey: import.meta.env.VITE_LANGFUSE_PUBLIC_KEY,
baseUrl: import.meta.env.VITE_LANGFUSE_BASE_URL,
})
publicKey: import.meta.env.VITE_LANGFUSE_PUBLIC_KEY,
baseUrl: import.meta.env.VITE_LANGFUSE_BASE_URL,
})
: null

interface FeedbackButtonsProps {
Expand All @@ -14,46 +16,97 @@ interface FeedbackButtonsProps {

export function FeedbackButtons({ traceId }: FeedbackButtonsProps) {
const [feedbackGiven, setFeedbackGiven] = useState<1 | -1 | null>(null)
const [isPopoverOpen, setIsPopoverOpen] = useState(false)

const handleFeedback = (value: 1 | -1) => {
const handleFeedback = (e: React.MouseEvent, value: 1 | -1) => {
// Stop event from propagating to the trigger so the trigger doesn't automatically open it when resetting
e.stopPropagation()
const next = feedbackGiven === value ? null : value
setFeedbackGiven(next)

if (langfuse && next !== null) {
if (next === null) {
// Re-clicking same button -> clear feedback
setIsPopoverOpen(false)
if (langfuse) {
langfuse.score({
id: `user-${traceId}`,
traceId,
name: 'user-thumbs',
value: 0,
dataType: 'NUMERIC',
comment: '',
})
}
} else {
// New feedback
setIsPopoverOpen(true)
if (langfuse) {
langfuse.score({
id: `user-${traceId}`,
traceId,
name: 'user-thumbs',
value: next,
dataType: 'NUMERIC',
})
}
}
}

const handleCommentSubmit = (comment: string) => {
setIsPopoverOpen(false)
if (langfuse && feedbackGiven !== null) {
langfuse.score({
id: `user-${traceId}`,
traceId,
name: 'user-thumbs',
value: next,
value: feedbackGiven,
dataType: 'NUMERIC',
comment,
})
}
}

return (
<div className="flex items-center gap-3 pt-2 mt-4 border-t border-gray-100 w-full">
<button
onClick={() => handleFeedback(1)}
className={`p-1 rounded hover:bg-gray-100 transition-colors ${feedbackGiven === 1
? 'text-primary'
: 'text-gray-400 hover:text-gray-600'
}`}
>
<span className="material-symbols-outlined text-[18px]">
thumb_up
</span>
</button>
<button
onClick={() => handleFeedback(-1)}
className={`p-1 rounded hover:bg-gray-100 transition-colors ${feedbackGiven === -1
? 'text-destructive'
: 'text-gray-400 hover:text-gray-600'
}`}
<Popover
open={isPopoverOpen}
onOpenChange={(open) => {
if (!open) setIsPopoverOpen(false)
}}
>
<span className="material-symbols-outlined text-[18px]">
thumb_down
</span>
</button>
<PopoverTrigger render={<div className="flex gap-3" />}>
<button
onClick={(e) => handleFeedback(e, 1)}
className={`p-1 rounded hover:bg-gray-100 transition-colors ${
feedbackGiven === 1
? 'text-primary'
: 'text-gray-400 hover:text-gray-600'
}`}
>
<span className="material-symbols-outlined text-[18px]">
thumb_up
</span>
</button>
<button
onClick={(e) => handleFeedback(e, -1)}
className={`p-1 rounded hover:bg-gray-100 transition-colors ${
feedbackGiven === -1
? 'text-destructive'
: 'text-gray-400 hover:text-gray-600'
}`}
>
<span className="material-symbols-outlined text-[18px]">
thumb_down
</span>
</button>
</PopoverTrigger>
<PopoverContent align="start">
<FeedbackPopoverContent
isPositive={feedbackGiven === 1}
onSubmit={handleCommentSubmit}
/>
</PopoverContent>
</Popover>
</div>
)
}
80 changes: 80 additions & 0 deletions src/components/FeedbackPopoverContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { useState } from 'react'
import { Checkbox } from './ui/checkbox'
import { Textarea } from './ui/textarea'
import { Button } from './ui/button'

const POSITIVE_OPTIONS = ['語氣適合', '篇幅適中', '出處精準', '具有說服力']

const NEGATIVE_OPTIONS = [
'篇幅過長',
'篇幅過短',
'沒抓到重點',
'出處不足',
'回應文字與出處不符',
'提供不存在的出處',
'出處摘要錯誤',
]

interface FeedbackPopoverContentProps {
isPositive: boolean
onSubmit: (comment: string) => void
}

export function FeedbackPopoverContent({
isPositive,
onSubmit,
}: FeedbackPopoverContentProps) {
const options = isPositive ? POSITIVE_OPTIONS : NEGATIVE_OPTIONS
const [selectedOptions, setSelectedOptions] = useState<Set<string>>(new Set())
const [comment, setComment] = useState('')

const handleToggleOption = (option: string) => {
const newOptions = new Set(selectedOptions)
if (newOptions.has(option)) {
newOptions.delete(option)
} else {
newOptions.add(option)
}
setSelectedOptions(newOptions)
}

const handleSubmit = () => {
let finalComment = ''
selectedOptions.forEach((opt) => {
finalComment += `☑ ${opt}\n`
})
if (comment.trim()) {
finalComment += comment.trim()
}
onSubmit(finalComment.trim())
}

return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<h4 className="font-medium text-sm">Tell us more</h4>
{options.map((option) => (
<label
key={option}
className="flex items-center gap-2 cursor-pointer text-sm"
>
<Checkbox
checked={selectedOptions.has(option)}
onCheckedChange={() => handleToggleOption(option)}
/>
{option}
</label>
))}
</div>
<Textarea
placeholder="Additional comments..."
value={comment}
onChange={(e) => setComment(e.target.value)}
className="min-h-[80px]"
/>
<Button onClick={handleSubmit} size="sm">
Submit
</Button>
</div>
)
}
29 changes: 29 additions & 0 deletions src/components/ui/checkbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"use client"

import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"

import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"

function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[6px] border border-input transition-shadow outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
Comment thread
MrOrz marked this conversation as resolved.
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}

export { Checkbox }
88 changes: 88 additions & 0 deletions src/components/ui/popover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import * as React from "react"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"

import { cn } from "@/lib/utils"

function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}

function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}

function PopoverContent({
Comment thread
MrOrz marked this conversation as resolved.
className,
align = "center",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
Comment thread
MrOrz marked this conversation as resolved.
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-4 rounded-2xl bg-popover p-4 text-sm text-popover-foreground shadow-2xl ring-1 ring-foreground/5 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}

function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-1 text-sm", className)}
{...props}
/>
)
}

function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
return (
<PopoverPrimitive.Title
data-slot="popover-title"
className={cn("text-base font-medium", className)}
{...props}
/>
)
}

function PopoverDescription({
className,
...props
}: PopoverPrimitive.Description.Props) {
return (
<PopoverPrimitive.Description
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}

export {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}
Loading