| | 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> |
| | ); |
| | } |
| |
|