|
import { useMemo, useState } from 'react'; |
|
import { useAppContext } from '../utils/app.context'; |
|
import { Message, PendingMessage } from '../utils/types'; |
|
import { classNames } from '../utils/misc'; |
|
import MarkdownDisplay, { CopyButton } from './MarkdownDisplay'; |
|
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline'; |
|
|
|
interface SplitMessage { |
|
content: PendingMessage['content']; |
|
thought?: string; |
|
isThinking?: boolean; |
|
} |
|
|
|
export default function ChatMessage({ |
|
msg, |
|
siblingLeafNodeIds, |
|
siblingCurrIdx, |
|
id, |
|
onRegenerateMessage, |
|
onEditMessage, |
|
onChangeSibling, |
|
isPending, |
|
}: { |
|
msg: Message | PendingMessage; |
|
siblingLeafNodeIds: Message['id'][]; |
|
siblingCurrIdx: number; |
|
id?: string; |
|
onRegenerateMessage(msg: Message): void; |
|
onEditMessage(msg: Message, content: string): void; |
|
onChangeSibling(sibling: Message['id']): void; |
|
isPending?: boolean; |
|
}) { |
|
const { viewingChat, config } = useAppContext(); |
|
const [editingContent, setEditingContent] = useState<string | null>(null); |
|
const timings = useMemo( |
|
() => |
|
msg.timings |
|
? { |
|
...msg.timings, |
|
prompt_per_second: |
|
(msg.timings.prompt_n / msg.timings.prompt_ms) * 1000, |
|
predicted_per_second: |
|
(msg.timings.predicted_n / msg.timings.predicted_ms) * 1000, |
|
} |
|
: null, |
|
[msg.timings] |
|
); |
|
const nextSibling = siblingLeafNodeIds[siblingCurrIdx + 1]; |
|
const prevSibling = siblingLeafNodeIds[siblingCurrIdx - 1]; |
|
|
|
|
|
|
|
const { content, thought, isThinking }: SplitMessage = useMemo(() => { |
|
if (msg.content === null || msg.role !== 'assistant') { |
|
return { content: msg.content }; |
|
} |
|
let actualContent = ''; |
|
let thought = ''; |
|
let isThinking = false; |
|
let thinkSplit = msg.content.split('<think>', 2); |
|
actualContent += thinkSplit[0]; |
|
while (thinkSplit[1] !== undefined) { |
|
|
|
thinkSplit = thinkSplit[1].split('</think>', 2); |
|
thought += thinkSplit[0]; |
|
isThinking = true; |
|
if (thinkSplit[1] !== undefined) { |
|
|
|
isThinking = false; |
|
thinkSplit = thinkSplit[1].split('<think>', 2); |
|
actualContent += thinkSplit[0]; |
|
} |
|
} |
|
return { content: actualContent, thought, isThinking }; |
|
}, [msg]); |
|
|
|
if (!viewingChat) return null; |
|
|
|
return ( |
|
<div className="group" id={id}> |
|
<div |
|
className={classNames({ |
|
chat: true, |
|
'chat-start': msg.role !== 'user', |
|
'chat-end': msg.role === 'user', |
|
})} |
|
> |
|
<div |
|
className={classNames({ |
|
'chat-bubble markdown': true, |
|
'chat-bubble-base-300': msg.role !== 'user', |
|
})} |
|
> |
|
{/* textarea for editing message */} |
|
{editingContent !== null && ( |
|
<> |
|
<textarea |
|
dir="auto" |
|
className="textarea textarea-bordered bg-base-100 text-base-content max-w-2xl w-[calc(90vw-8em)] h-24" |
|
value={editingContent} |
|
onChange={(e) => setEditingContent(e.target.value)} |
|
></textarea> |
|
<br /> |
|
<button |
|
className="btn btn-ghost mt-2 mr-2" |
|
onClick={() => setEditingContent(null)} |
|
> |
|
Cancel |
|
</button> |
|
<button |
|
className="btn mt-2" |
|
onClick={() => { |
|
if (msg.content !== null) { |
|
setEditingContent(null); |
|
onEditMessage(msg as Message, editingContent); |
|
} |
|
}} |
|
> |
|
Submit |
|
</button> |
|
</> |
|
)} |
|
{/* not editing content, render message */} |
|
{editingContent === null && ( |
|
<> |
|
{content === null ? ( |
|
<> |
|
{/* show loading dots for pending message */} |
|
<span className="loading loading-dots loading-md"></span> |
|
</> |
|
) : ( |
|
<> |
|
{/* render message as markdown */} |
|
<div dir="auto"> |
|
{thought && ( |
|
<details |
|
className="collapse bg-base-200 collapse-arrow mb-4" |
|
open={isThinking && config.showThoughtInProgress} |
|
> |
|
<summary className="collapse-title"> |
|
{isPending && isThinking ? ( |
|
<span> |
|
<span |
|
v-if="isGenerating" |
|
className="loading loading-spinner loading-md mr-2" |
|
style={{ verticalAlign: 'middle' }} |
|
></span> |
|
<b>Thinking</b> |
|
</span> |
|
) : ( |
|
<b>Thought Process</b> |
|
)} |
|
</summary> |
|
<div className="collapse-content"> |
|
<MarkdownDisplay |
|
content={thought} |
|
isGenerating={isPending} |
|
/> |
|
</div> |
|
</details> |
|
)} |
|
|
|
{msg.extra && msg.extra.length > 0 && ( |
|
<details |
|
className={classNames({ |
|
'collapse collapse-arrow mb-4 bg-base-200': true, |
|
'bg-opacity-10': msg.role !== 'assistant', |
|
})} |
|
> |
|
<summary className="collapse-title"> |
|
Extra content |
|
</summary> |
|
<div className="collapse-content"> |
|
{msg.extra.map( |
|
(extra, i) => |
|
extra.type === 'textFile' ? ( |
|
<div key={extra.name}> |
|
<b>{extra.name}</b> |
|
<pre>{extra.content}</pre> |
|
</div> |
|
) : extra.type === 'context' ? ( |
|
<div key={i}> |
|
<pre>{extra.content}</pre> |
|
</div> |
|
) : null // TODO: support other extra types |
|
)} |
|
</div> |
|
</details> |
|
)} |
|
|
|
<MarkdownDisplay |
|
content={content} |
|
isGenerating={isPending} |
|
/> |
|
</div> |
|
</> |
|
)} |
|
{} |
|
{timings && config.showTokensPerSecond && ( |
|
<div className="dropdown dropdown-hover dropdown-top mt-2"> |
|
<div |
|
tabIndex={0} |
|
role="button" |
|
className="cursor-pointer font-semibold text-sm opacity-60" |
|
> |
|
Speed: {timings.predicted_per_second.toFixed(1)} t/s |
|
</div> |
|
<div className="dropdown-content bg-base-100 z-10 w-64 p-2 shadow mt-4"> |
|
<b>Prompt</b> |
|
<br />- Tokens: {timings.prompt_n} |
|
<br />- Time: {timings.prompt_ms} ms |
|
<br />- Speed: {timings.prompt_per_second.toFixed(1)} t/s |
|
<br /> |
|
<b>Generation</b> |
|
<br />- Tokens: {timings.predicted_n} |
|
<br />- Time: {timings.predicted_ms} ms |
|
<br />- Speed: {timings.predicted_per_second.toFixed(1)} t/s |
|
<br /> |
|
</div> |
|
</div> |
|
)} |
|
</> |
|
)} |
|
</div> |
|
</div> |
|
|
|
{} |
|
{msg.content !== null && ( |
|
<div |
|
className={classNames({ |
|
'flex items-center gap-2 mx-4 mt-2 mb-2': true, |
|
'flex-row-reverse': msg.role === 'user', |
|
})} |
|
> |
|
{siblingLeafNodeIds && siblingLeafNodeIds.length > 1 && ( |
|
<div className="flex gap-1 items-center opacity-60 text-sm"> |
|
<button |
|
className={classNames({ |
|
'btn btn-sm btn-ghost p-1': true, |
|
'opacity-20': !prevSibling, |
|
})} |
|
onClick={() => prevSibling && onChangeSibling(prevSibling)} |
|
> |
|
<ChevronLeftIcon className="h-4 w-4" /> |
|
</button> |
|
<span> |
|
{siblingCurrIdx + 1} / {siblingLeafNodeIds.length} |
|
</span> |
|
<button |
|
className={classNames({ |
|
'btn btn-sm btn-ghost p-1': true, |
|
'opacity-20': !nextSibling, |
|
})} |
|
onClick={() => nextSibling && onChangeSibling(nextSibling)} |
|
> |
|
<ChevronRightIcon className="h-4 w-4" /> |
|
</button> |
|
</div> |
|
)} |
|
{/* user message */} |
|
{msg.role === 'user' && ( |
|
<button |
|
className="badge btn-mini show-on-hover" |
|
onClick={() => setEditingContent(msg.content)} |
|
disabled={msg.content === null} |
|
> |
|
✍️ Edit |
|
</button> |
|
)} |
|
{/* assistant message */} |
|
{msg.role === 'assistant' && ( |
|
<> |
|
{!isPending && ( |
|
<button |
|
className="badge btn-mini show-on-hover mr-2" |
|
onClick={() => { |
|
if (msg.content !== null) { |
|
onRegenerateMessage(msg as Message); |
|
} |
|
}} |
|
disabled={msg.content === null} |
|
> |
|
🔄 Regenerate |
|
</button> |
|
)} |
|
</> |
|
)} |
|
<CopyButton |
|
className="badge btn-mini show-on-hover mr-2" |
|
content={msg.content} |
|
/> |
|
</div> |
|
)} |
|
</div> |
|
); |
|
} |
|
|