230 lines
7.5 KiB
TypeScript
230 lines
7.5 KiB
TypeScript
|
|
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';
|
|||
|
|
|
|||
|
|
interface SplitMessage {
|
|||
|
|
content: PendingMessage['content'];
|
|||
|
|
thought?: string;
|
|||
|
|
isThinking?: boolean;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export default function ChatMessage({
|
|||
|
|
msg,
|
|||
|
|
id,
|
|||
|
|
scrollToBottom,
|
|||
|
|
isPending,
|
|||
|
|
}: {
|
|||
|
|
msg: Message | PendingMessage;
|
|||
|
|
id?: string;
|
|||
|
|
scrollToBottom: (requiresNearBottom: boolean) => void;
|
|||
|
|
isPending?: boolean;
|
|||
|
|
}) {
|
|||
|
|
const { viewingConversation, replaceMessageAndGenerate, 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]
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// for reasoning model, we split the message into content and thought
|
|||
|
|
// TODO: implement this as remark/rehype plugin in the future
|
|||
|
|
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) {
|
|||
|
|
// <think> tag found
|
|||
|
|
thinkSplit = thinkSplit[1].split('</think>', 2);
|
|||
|
|
thought += thinkSplit[0];
|
|||
|
|
isThinking = true;
|
|||
|
|
if (thinkSplit[1] !== undefined) {
|
|||
|
|
// </think> closing tag found
|
|||
|
|
isThinking = false;
|
|||
|
|
thinkSplit = thinkSplit[1].split('<think>', 2);
|
|||
|
|
actualContent += thinkSplit[0];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return { content: actualContent, thought, isThinking };
|
|||
|
|
}, [msg]);
|
|||
|
|
|
|||
|
|
if (!viewingConversation) return null;
|
|||
|
|
|
|||
|
|
const regenerate = async () => {
|
|||
|
|
replaceMessageAndGenerate(viewingConversation.id, msg.id, undefined, () =>
|
|||
|
|
scrollToBottom(true)
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
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 w-[calc(90vw-8em)] lg:w-96"
|
|||
|
|
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={() =>
|
|||
|
|
replaceMessageAndGenerate(
|
|||
|
|
viewingConversation.id,
|
|||
|
|
msg.id,
|
|||
|
|
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} />
|
|||
|
|
</div>
|
|||
|
|
</details>
|
|||
|
|
)}
|
|||
|
|
<MarkdownDisplay content={content} />
|
|||
|
|
</div>
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
{/* render timings if enabled */}
|
|||
|
|
{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>
|
|||
|
|
|
|||
|
|
{/* actions for each message */}
|
|||
|
|
{msg.content !== null && (
|
|||
|
|
<div
|
|||
|
|
className={classNames({
|
|||
|
|
'mx-4 mt-2 mb-2': true,
|
|||
|
|
'text-right': msg.role === 'user',
|
|||
|
|
})}
|
|||
|
|
>
|
|||
|
|
{/* 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={regenerate}
|
|||
|
|
disabled={msg.content === null}
|
|||
|
|
>
|
|||
|
|
🔄 Regenerate
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
<CopyButton
|
|||
|
|
className="badge btn-mini show-on-hover mr-2"
|
|||
|
|
content={msg.content}
|
|||
|
|
/>
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|