-
Notifications
You must be signed in to change notification settings - Fork 25
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
3 changed files
with
125 additions
and
59 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
144 changes: 86 additions & 58 deletions
144
frontend/app/src/components/chat/message-annotation-history.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,86 +1,114 @@ | ||
import { useChatMessageStreamHistoryStates, useChatMessageStreamState } from '@/components/chat/chat-hooks'; | ||
import type { ChatMessageController } from '@/components/chat/chat-message-controller'; | ||
import { isNotFinished } from '@/components/chat/utils'; | ||
import { DiffSeconds } from '@/components/diff-seconds'; | ||
import { Button, buttonVariants } from '@/components/ui/button'; | ||
import { cn } from '@/lib/utils'; | ||
import { differenceInMilliseconds } from 'date-fns'; | ||
import { motion } from 'framer-motion'; | ||
import { CheckCircleIcon, Loader2Icon } from 'lucide-react'; | ||
import { CheckCircleIcon, ChevronUpIcon, ClockIcon, Loader2Icon } from 'lucide-react'; | ||
import { useEffect, useState } from 'react'; | ||
|
||
const CheckedCircle = motion(CheckCircleIcon); | ||
|
||
export function MessageAnnotationHistory ({ message }: { message: ChatMessageController | undefined }) { | ||
const [show, setShow] = useState(true); | ||
const history = useChatMessageStreamHistoryStates(message); | ||
const current = useChatMessageStreamState(message); | ||
const [_, setV] = useState(0); | ||
|
||
useEffect(() => { | ||
if (current && !current.finished) { | ||
const interval = setInterval(() => { | ||
setV(v => v + 1); | ||
}, 100); | ||
const finished = !isNotFinished(current); | ||
|
||
return () => clearInterval(interval); | ||
useEffect(() => { | ||
if (finished) { | ||
const handler = setTimeout(() => { | ||
setShow(false); | ||
}, 2000); | ||
return () => { | ||
clearTimeout(handler); | ||
}; | ||
} | ||
}, [current]); | ||
}, [finished]); | ||
|
||
return ( | ||
<ol className="text-sm"> | ||
{history?.map(({ state, time }, index, history) => ( | ||
index > 0 && ( | ||
<motion.li | ||
className={cn('relative mb-2', index === history.length - 1 && current && !current.finished && 'mb-2')} | ||
key={index} | ||
<div className="!mt-1"> | ||
<motion.div | ||
animate={show ? { height: 'auto', opacity: 1, scale: 1, pointerEvents: 'auto' } : { height: 0, opacity: 0, scale: 0.3, pointerEvents: 'none' }} | ||
style={{ | ||
transformOrigin: 'left top', | ||
}} | ||
> | ||
<ol | ||
className="text-sm" | ||
> | ||
{history?.map(({ state, time }, index, history) => ( | ||
index > 0 && ( | ||
<motion.li | ||
className={cn('relative mb-2', index === history.length - 1 && current && !current.finished && 'mb-2')} | ||
key={index} | ||
initial={{ | ||
opacity: 0.5, | ||
}} | ||
animate={{ | ||
opacity: 1, | ||
}} | ||
> | ||
{index > 1 && <span className="absolute left-2 bg-green-500 h-2" style={{ width: 1, top: -8 }} />} | ||
<div className="flex gap-2 items-center"> | ||
<CheckedCircle | ||
className="size-4 text-green-500" | ||
initial={{ | ||
color: 'rgb(113 113 122)', | ||
}} | ||
animate={{ | ||
color: 'rgb(34 197 94)', | ||
}} | ||
/> | ||
<span> | ||
{state.display} | ||
</span> | ||
{index > 0 && <DiffSeconds className="text-muted-foreground text-xs" from={history[index - 1].time} to={time} />} | ||
</div> | ||
{state.message && <div className="ml-2 pl-4 text-muted-foreground text-xs border-l border-l-green-500 pt-1">{state.message}</div>} | ||
</motion.li> | ||
) | ||
))} | ||
{current && !current.finished && <motion.li | ||
key={current.state} | ||
className="relative space-y-1" | ||
initial={{ | ||
opacity: 0.5, | ||
opacity: 0, | ||
height: 0, | ||
x: -40, | ||
}} | ||
animate={{ | ||
opacity: 1, | ||
opacity: 0.5, | ||
height: 'auto', | ||
x: 0, | ||
}} | ||
> | ||
{index > 1 && <span className="absolute left-2 bg-green-500 h-2" style={{ width: 1, top: -8 }} />} | ||
<div className="flex gap-2 items-center"> | ||
<CheckedCircle | ||
className="size-4 text-green-500" | ||
initial={{ | ||
color: 'rgb(113 113 122)', | ||
}} | ||
animate={{ | ||
color: 'rgb(34 197 94)', | ||
}} | ||
/> | ||
{(history?.length ?? 0) > 1 && <span className="absolute left-2 opacity-50 bg-zinc-500 h-2" style={{ width: 1, top: -8 }} />} | ||
<Loader2Icon className="size-4 animate-spin repeat-infinite text-muted-foreground" /> | ||
<span> | ||
{state.display} | ||
{current.display} | ||
</span> | ||
{index > 0 && <time className="text-muted-foreground text-xs">{(differenceInMilliseconds(time, history[index - 1].time) / 1000).toFixed(1)}s</time>} | ||
{history && history.length > 0 && <DiffSeconds className="text-muted-foreground text-xs" from={history[history.length - 1].time} />} | ||
</div> | ||
{state.message && <div className="ml-2 pl-4 text-muted-foreground text-xs border-l border-l-green-500 pt-1">{state.message}</div>} | ||
</motion.li> | ||
) | ||
))} | ||
{current && !current.finished && <motion.li | ||
key={current.state} | ||
className="relative space-y-1" | ||
initial={{ | ||
opacity: 0, | ||
height: 0, | ||
x: -40, | ||
}} | ||
animate={{ | ||
opacity: 0.5, | ||
height: 'auto', | ||
x: 0, | ||
}} | ||
{current.message && <div className="ml-2 pl-4 text-muted-foreground text-xs border-l border-l-green-500 pt-1">{current.message}</div>} | ||
</motion.li>} | ||
</ol> | ||
<Button variant="ghost" size="sm" className="text-muted-foreground h-auto py-1 text-xs mr-1" onClick={() => setShow(false)}> | ||
<ChevronUpIcon className="size-4 mr-1" /> | ||
Collapse | ||
</Button> | ||
</motion.div> | ||
{history?.length && <motion.button | ||
onClick={() => setShow(true)} | ||
className={cn('flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors')} | ||
animate={show ? { height: 0, opacity: 0, overflow: 'visible', pointerEvents: 'none', scale: 0.5 } : { height: 'auto', opacity: 1, scale: 1, pointerEvents: 'auto' }} | ||
> | ||
<div className="flex gap-2 items-center"> | ||
{(history?.length ?? 0) > 1 && <span className="absolute left-2 opacity-50 bg-zinc-500 h-2" style={{ width: 1, top: -8 }} />} | ||
<Loader2Icon className="size-4 animate-spin repeat-infinite text-muted-foreground" /> | ||
<span> | ||
{current.display} | ||
</span> | ||
{history && history.length > 0 && <time className="text-muted-foreground text-xs">{(differenceInMilliseconds(new Date(), history[history.length - 1].time) / 1000).toFixed(1)}s</time>} | ||
</div> | ||
{current.message && <div className="ml-2 pl-4 text-muted-foreground text-xs border-l border-l-green-500 pt-1">{current.message}</div>} | ||
</motion.li>} | ||
</ol> | ||
<ClockIcon className="size-3" /> | ||
<DiffSeconds from={message?.message?.created_at} to={message?.message?.finished_at} /> | ||
</motion.button>} | ||
</div> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
import { differenceInMilliseconds } from 'date-fns'; | ||
import { useEffect, useState } from 'react'; | ||
|
||
function diff (from: Date | string | number | null | undefined, to: Date | string | number | null | undefined) { | ||
if (from == null) { | ||
return null; | ||
} | ||
return (differenceInMilliseconds(to ?? new Date(), from) / 1000).toFixed(1) + 's'; | ||
} | ||
|
||
/** | ||
* | ||
* @param className | ||
* @param from | ||
* @param to default to now | ||
* @constructor | ||
*/ | ||
export function DiffSeconds ({ className, from, to }: { className?: string, from: Date | string | number | null | undefined, to?: Date | string | number | null | undefined }) { | ||
const [seconds, setSeconds] = useState(() => diff(from, to)); | ||
|
||
useEffect(() => { | ||
if (from == null) { | ||
return; | ||
} | ||
setSeconds(diff(from, to)); | ||
if (to == null) { | ||
const interval = setInterval(() => { | ||
setSeconds(diff(from, to)); | ||
}, 100); | ||
|
||
return () => { | ||
clearInterval(interval); | ||
}; | ||
} | ||
}, [from, to]); | ||
|
||
return <time className={className}>{seconds}</time>; | ||
} |