ChatMessage.tsx 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. import { useMemo, useState } from 'react';
  2. import { useAppContext } from '../utils/app.context';
  3. import { Message, PendingMessage } from '../utils/types';
  4. import { classNames } from '../utils/misc';
  5. import MarkdownDisplay, { CopyButton } from './MarkdownDisplay';
  6. import {
  7. ArrowPathIcon,
  8. ChevronLeftIcon,
  9. ChevronRightIcon,
  10. PencilSquareIcon,
  11. } from '@heroicons/react/24/outline';
  12. import ChatInputExtraContextItem from './ChatInputExtraContextItem';
  13. import { BtnWithTooltips } from '../utils/common';
  14. interface SplitMessage {
  15. content: PendingMessage['content'];
  16. thought?: string;
  17. isThinking?: boolean;
  18. }
  19. export default function ChatMessage({
  20. msg,
  21. siblingLeafNodeIds,
  22. siblingCurrIdx,
  23. id,
  24. onRegenerateMessage,
  25. onEditMessage,
  26. onChangeSibling,
  27. isPending,
  28. }: {
  29. msg: Message | PendingMessage;
  30. siblingLeafNodeIds: Message['id'][];
  31. siblingCurrIdx: number;
  32. id?: string;
  33. onRegenerateMessage(msg: Message): void;
  34. onEditMessage(msg: Message, content: string): void;
  35. onChangeSibling(sibling: Message['id']): void;
  36. isPending?: boolean;
  37. }) {
  38. const { viewingChat, config } = useAppContext();
  39. const [editingContent, setEditingContent] = useState<string | null>(null);
  40. const timings = useMemo(
  41. () =>
  42. msg.timings
  43. ? {
  44. ...msg.timings,
  45. prompt_per_second:
  46. (msg.timings.prompt_n / msg.timings.prompt_ms) * 1000,
  47. predicted_per_second:
  48. (msg.timings.predicted_n / msg.timings.predicted_ms) * 1000,
  49. }
  50. : null,
  51. [msg.timings]
  52. );
  53. const nextSibling = siblingLeafNodeIds[siblingCurrIdx + 1];
  54. const prevSibling = siblingLeafNodeIds[siblingCurrIdx - 1];
  55. // for reasoning model, we split the message into content and thought
  56. // TODO: implement this as remark/rehype plugin in the future
  57. const { content, thought, isThinking }: SplitMessage = useMemo(() => {
  58. if (msg.content === null || msg.role !== 'assistant') {
  59. return { content: msg.content };
  60. }
  61. let actualContent = '';
  62. let thought = '';
  63. let isThinking = false;
  64. let thinkSplit = msg.content.split('<think>', 2);
  65. actualContent += thinkSplit[0];
  66. while (thinkSplit[1] !== undefined) {
  67. // <think> tag found
  68. thinkSplit = thinkSplit[1].split('</think>', 2);
  69. thought += thinkSplit[0];
  70. isThinking = true;
  71. if (thinkSplit[1] !== undefined) {
  72. // </think> closing tag found
  73. isThinking = false;
  74. thinkSplit = thinkSplit[1].split('<think>', 2);
  75. actualContent += thinkSplit[0];
  76. }
  77. }
  78. return { content: actualContent, thought, isThinking };
  79. }, [msg]);
  80. if (!viewingChat) return null;
  81. return (
  82. <div className="group" id={id}>
  83. <div
  84. className={classNames({
  85. chat: true,
  86. 'chat-start': msg.role !== 'user',
  87. 'chat-end': msg.role === 'user',
  88. })}
  89. >
  90. {msg.extra && msg.extra.length > 0 && (
  91. <ChatInputExtraContextItem items={msg.extra} clickToShow />
  92. )}
  93. <div
  94. className={classNames({
  95. 'chat-bubble markdown': true,
  96. 'chat-bubble bg-transparent': msg.role !== 'user',
  97. })}
  98. >
  99. {/* textarea for editing message */}
  100. {editingContent !== null && (
  101. <>
  102. <textarea
  103. dir="auto"
  104. className="textarea textarea-bordered bg-base-100 text-base-content max-w-2xl w-[calc(90vw-8em)] h-24"
  105. value={editingContent}
  106. onChange={(e) => setEditingContent(e.target.value)}
  107. ></textarea>
  108. <br />
  109. <button
  110. className="btn btn-ghost mt-2 mr-2"
  111. onClick={() => setEditingContent(null)}
  112. >
  113. Cancel
  114. </button>
  115. <button
  116. className="btn mt-2"
  117. onClick={() => {
  118. if (msg.content !== null) {
  119. setEditingContent(null);
  120. onEditMessage(msg as Message, editingContent);
  121. }
  122. }}
  123. >
  124. Submit
  125. </button>
  126. </>
  127. )}
  128. {/* not editing content, render message */}
  129. {editingContent === null && (
  130. <>
  131. {content === null ? (
  132. <>
  133. {/* show loading dots for pending message */}
  134. <span className="loading loading-dots loading-md"></span>
  135. </>
  136. ) : (
  137. <>
  138. {/* render message as markdown */}
  139. <div dir="auto">
  140. {thought && (
  141. <ThoughtProcess
  142. isThinking={!!isThinking && !!isPending}
  143. content={thought}
  144. open={config.showThoughtInProgress}
  145. />
  146. )}
  147. <MarkdownDisplay
  148. content={content}
  149. isGenerating={isPending}
  150. />
  151. </div>
  152. </>
  153. )}
  154. {/* render timings if enabled */}
  155. {timings && config.showTokensPerSecond && (
  156. <div className="dropdown dropdown-hover dropdown-top mt-2">
  157. <div
  158. tabIndex={0}
  159. role="button"
  160. className="cursor-pointer font-semibold text-sm opacity-60"
  161. >
  162. Speed: {timings.predicted_per_second.toFixed(1)} t/s
  163. </div>
  164. <div className="dropdown-content bg-base-100 z-10 w-64 p-2 shadow mt-4">
  165. <b>Prompt</b>
  166. <br />- Tokens: {timings.prompt_n}
  167. <br />- Time: {timings.prompt_ms} ms
  168. <br />- Speed: {timings.prompt_per_second.toFixed(1)} t/s
  169. <br />
  170. <b>Generation</b>
  171. <br />- Tokens: {timings.predicted_n}
  172. <br />- Time: {timings.predicted_ms} ms
  173. <br />- Speed: {timings.predicted_per_second.toFixed(1)} t/s
  174. <br />
  175. </div>
  176. </div>
  177. )}
  178. </>
  179. )}
  180. </div>
  181. </div>
  182. {/* actions for each message */}
  183. {msg.content !== null && (
  184. <div
  185. className={classNames({
  186. 'flex items-center gap-2 mx-4 mt-2 mb-2': true,
  187. 'flex-row-reverse': msg.role === 'user',
  188. })}
  189. >
  190. {siblingLeafNodeIds && siblingLeafNodeIds.length > 1 && (
  191. <div className="flex gap-1 items-center opacity-60 text-sm">
  192. <button
  193. className={classNames({
  194. 'btn btn-sm btn-ghost p-1': true,
  195. 'opacity-20': !prevSibling,
  196. })}
  197. onClick={() => prevSibling && onChangeSibling(prevSibling)}
  198. >
  199. <ChevronLeftIcon className="h-4 w-4" />
  200. </button>
  201. <span>
  202. {siblingCurrIdx + 1} / {siblingLeafNodeIds.length}
  203. </span>
  204. <button
  205. className={classNames({
  206. 'btn btn-sm btn-ghost p-1': true,
  207. 'opacity-20': !nextSibling,
  208. })}
  209. onClick={() => nextSibling && onChangeSibling(nextSibling)}
  210. >
  211. <ChevronRightIcon className="h-4 w-4" />
  212. </button>
  213. </div>
  214. )}
  215. {/* user message */}
  216. {msg.role === 'user' && (
  217. <BtnWithTooltips
  218. className="btn-mini show-on-hover w-8 h-8"
  219. onClick={() => setEditingContent(msg.content)}
  220. disabled={msg.content === null}
  221. tooltipsContent="Edit message"
  222. >
  223. <PencilSquareIcon className="h-4 w-4" />
  224. </BtnWithTooltips>
  225. )}
  226. {/* assistant message */}
  227. {msg.role === 'assistant' && (
  228. <>
  229. {!isPending && (
  230. <BtnWithTooltips
  231. className="btn-mini show-on-hover w-8 h-8"
  232. onClick={() => {
  233. if (msg.content !== null) {
  234. onRegenerateMessage(msg as Message);
  235. }
  236. }}
  237. disabled={msg.content === null}
  238. tooltipsContent="Regenerate response"
  239. >
  240. <ArrowPathIcon className="h-4 w-4" />
  241. </BtnWithTooltips>
  242. )}
  243. </>
  244. )}
  245. <CopyButton
  246. className="btn-mini show-on-hover w-8 h-8"
  247. content={msg.content}
  248. />
  249. </div>
  250. )}
  251. </div>
  252. );
  253. }
  254. function ThoughtProcess({
  255. isThinking,
  256. content,
  257. open,
  258. }: {
  259. isThinking: boolean;
  260. content: string;
  261. open: boolean;
  262. }) {
  263. return (
  264. <div
  265. tabIndex={0}
  266. className={classNames({
  267. 'collapse bg-none': true,
  268. })}
  269. >
  270. <input type="checkbox" defaultChecked={open} />
  271. <div className="collapse-title px-0">
  272. <div className="btn rounded-xl">
  273. {isThinking ? (
  274. <span>
  275. <span
  276. className="loading loading-spinner loading-md mr-2"
  277. style={{ verticalAlign: 'middle' }}
  278. ></span>
  279. Thinking
  280. </span>
  281. ) : (
  282. <>Thought Process</>
  283. )}
  284. </div>
  285. </div>
  286. <div className="collapse-content text-base-content/70 text-sm p-1">
  287. <div className="border-l-2 border-base-content/20 pl-4 mb-4">
  288. <MarkdownDisplay content={content} />
  289. </div>
  290. </div>
  291. </div>
  292. );
  293. }