ChatMessage.tsx 10 KB

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