KBaba7/llama.cpp
0
1import { useMemo, useState } from 'react';2import { useAppContext } from '../utils/app.context';3import { Message, PendingMessage } from '../utils/types';4import { classNames } from '../utils/misc';5import MarkdownDisplay, { CopyButton } from './MarkdownDisplay';6 7interface SplitMessage {8 content: PendingMessage['content'];9 thought?: string;10 isThinking?: boolean;11}12 13export default function ChatMessage({14 msg,15 id,16 scrollToBottom,17 isPending,18}: {19 msg: Message | PendingMessage;20 id?: string;21 scrollToBottom: (requiresNearBottom: boolean) => void;22 isPending?: boolean;23}) {24 const { viewingConversation, replaceMessageAndGenerate, config } =25 useAppContext();26 const [editingContent, setEditingContent] = useState<string | null>(null);27 const timings = useMemo(28 () =>29 msg.timings30 ? {31 ...msg.timings,32 prompt_per_second:33 (msg.timings.prompt_n / msg.timings.prompt_ms) * 1000,34 predicted_per_second:35 (msg.timings.predicted_n / msg.timings.predicted_ms) * 1000,36 }37 : null,38 [msg.timings]39 );40 41 // for reasoning model, we split the message into content and thought42 // TODO: implement this as remark/rehype plugin in the future43 const { content, thought, isThinking }: SplitMessage = useMemo(() => {44 if (msg.content === null || msg.role !== 'assistant') {45 return { content: msg.content };46 }47 let actualContent = '';48 let thought = '';49 let isThinking = false;50 let thinkSplit = msg.content.split('<think>', 2);51 actualContent += thinkSplit[0];52 while (thinkSplit[1] !== undefined) {53 // <think> tag found54 thinkSplit = thinkSplit[1].split('</think>', 2);55 thought += thinkSplit[0];56 isThinking = true;57 if (thinkSplit[1] !== undefined) {58 // </think> closing tag found59 isThinking = false;60 thinkSplit = thinkSplit[1].split('<think>', 2);61 actualContent += thinkSplit[0];62 }63 }64 return { content: actualContent, thought, isThinking };65 }, [msg]);66 67 if (!viewingConversation) return null;68 69 const regenerate = async () => {70 replaceMessageAndGenerate(viewingConversation.id, msg.id, undefined, () =>71 scrollToBottom(true)72 );73 };74 75 return (76 <div className="group" id={id}>77 <div78 className={classNames({79 chat: true,80 'chat-start': msg.role !== 'user',81 'chat-end': msg.role === 'user',82 })}83 >84 <div85 className={classNames({86 'chat-bubble markdown': true,87 'chat-bubble-base-300': msg.role !== 'user',88 })}89 >90 {/* textarea for editing message */}91 {editingContent !== null && (92 <>93 <textarea94 dir="auto"95 className="textarea textarea-bordered bg-base-100 text-base-content w-[calc(90vw-8em)] lg:w-96"96 value={editingContent}97 onChange={(e) => setEditingContent(e.target.value)}98 ></textarea>99 <br />100 <button101 className="btn btn-ghost mt-2 mr-2"102 onClick={() => setEditingContent(null)}103 >104 Cancel105 </button>106 <button107 className="btn mt-2"108 onClick={() =>109 replaceMessageAndGenerate(110 viewingConversation.id,111 msg.id,112 editingContent113 )114 }115 >116 Submit117 </button>118 </>119 )}120 {/* not editing content, render message */}121 {editingContent === null && (122 <>123 {content === null ? (124 <>125 {/* show loading dots for pending message */}126 <span className="loading loading-dots loading-md"></span>127 </>128 ) : (129 <>130 {/* render message as markdown */}131 <div dir="auto">132 {thought && (133 <details134 className="collapse bg-base-200 collapse-arrow mb-4"135 open={isThinking && config.showThoughtInProgress}136 >137 <summary className="collapse-title">138 {isPending && isThinking ? (139 <span>140 <span141 v-if="isGenerating"142 className="loading loading-spinner loading-md mr-2"143 style={{ verticalAlign: 'middle' }}144 ></span>145 <b>Thinking</b>146 </span>147 ) : (148 <b>Thought Process</b>149 )}150 </summary>151 <div className="collapse-content">152 <MarkdownDisplay content={thought} />153 </div>154 </details>155 )}156 <MarkdownDisplay content={content} />157 </div>158 </>159 )}160 {/* render timings if enabled */}161 {timings && config.showTokensPerSecond && (162 <div className="dropdown dropdown-hover dropdown-top mt-2">163 <div164 tabIndex={0}165 role="button"166 className="cursor-pointer font-semibold text-sm opacity-60"167 >168 Speed: {timings.predicted_per_second.toFixed(1)} t/s169 </div>170 <div className="dropdown-content bg-base-100 z-10 w-64 p-2 shadow mt-4">171 <b>Prompt</b>172 <br />- Tokens: {timings.prompt_n}173 <br />- Time: {timings.prompt_ms} ms174 <br />- Speed: {timings.prompt_per_second.toFixed(1)} t/s175 <br />176 <b>Generation</b>177 <br />- Tokens: {timings.predicted_n}178 <br />- Time: {timings.predicted_ms} ms179 <br />- Speed: {timings.predicted_per_second.toFixed(1)} t/s180 <br />181 </div>182 </div>183 )}184 </>185 )}186 </div>187 </div>188 189 {/* actions for each message */}190 {msg.content !== null && (191 <div192 className={classNames({193 'mx-4 mt-2 mb-2': true,194 'text-right': msg.role === 'user',195 })}196 >197 {/* user message */}198 {msg.role === 'user' && (199 <button200 className="badge btn-mini show-on-hover"201 onClick={() => setEditingContent(msg.content)}202 disabled={msg.content === null}203 >204 โ๏ธ Edit205 </button>206 )}207 {/* assistant message */}208 {msg.role === 'assistant' && (209 <>210 {!isPending && (211 <button212 className="badge btn-mini show-on-hover mr-2"213 onClick={regenerate}214 disabled={msg.content === null}215 >216 ๐ Regenerate217 </button>218 )}219 <CopyButton220 className="badge btn-mini show-on-hover mr-2"221 content={msg.content}222 />223 </>224 )}225 </div>226 )}227 </div>228 );229}230 