这是indexloc提供的服务,不要输入任何密码
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ import createDOMPurify from "dompurify";
import { EditMessageForm, useEditMessage } from "./Actions/EditMessage";
import { useWatchDeleteMessage } from "./Actions/DeleteMessage";
import TTSMessage from "./Actions/TTSButton";
import {
THOUGHT_REGEX_CLOSE,
THOUGHT_REGEX_COMPLETE,
THOUGHT_REGEX_OPEN,
ThoughtChainComponent,
} from "../ThoughtContainer";

const DOMPurify = createDOMPurify(window);
const HistoricalMessage = ({
Expand Down Expand Up @@ -97,11 +103,10 @@ const HistoricalMessage = ({
/>
) : (
<div className="break-words">
<span
className="flex flex-col gap-y-1"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(renderMarkdown(message)),
}}
<RenderChatContent
role={role}
message={message}
expanded={isLastMessage}
/>
<ChatAttachments attachments={attachments} />
</div>
Expand Down Expand Up @@ -179,3 +184,62 @@ function ChatAttachments({ attachments = [] }) {
</div>
);
}

const RenderChatContent = memo(
({ role, message, expanded = false }) => {
// If the message is not from the assistant, we can render it directly
// as normal since the user cannot think (lol)
if (role !== "assistant")
return (
<span
className="flex flex-col gap-y-1"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(renderMarkdown(message)),
}}
/>
);
let thoughtChain = null;
let msgToRender = message;

// If the message is a perfect thought chain, we can render it directly
// Complete == open and close tags match perfectly.
if (message.match(THOUGHT_REGEX_COMPLETE)) {
thoughtChain = message.match(THOUGHT_REGEX_COMPLETE)?.[0];
msgToRender = message.replace(THOUGHT_REGEX_COMPLETE, "");
}

// If the message is a thought chain but not a complete thought chain (matching opening tags but not closing tags),
// we can render it as a thought chain if we can at least find a closing tag
// This can occur when the assistant starts with <thinking> and then <response>'s later.
if (
message.match(THOUGHT_REGEX_OPEN) &&
message.match(THOUGHT_REGEX_CLOSE)
) {
const closingTag = message.match(THOUGHT_REGEX_CLOSE)?.[0];
const splitMessage = message.split(closingTag);
thoughtChain = splitMessage[0] + closingTag;
msgToRender = splitMessage[1];
}

return (
<>
{thoughtChain && (
<ThoughtChainComponent content={thoughtChain} expanded={expanded} />
)}
<span
className="flex flex-col gap-y-1"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(renderMarkdown(msgToRender)),
}}
/>
</>
);
},
(prevProps, nextProps) => {
return (
prevProps.role === nextProps.role &&
prevProps.message === nextProps.message &&
prevProps.expanded === nextProps.expanded
);
}
);
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { memo } from "react";
import { memo, useRef, useEffect } from "react";
import { Warning } from "@phosphor-icons/react";
import UserIcon from "../../../../UserIcon";
import renderMarkdown from "@/utils/chat/markdown";
import Citations from "../Citation";
import {
THOUGHT_REGEX_CLOSE,
THOUGHT_REGEX_COMPLETE,
THOUGHT_REGEX_OPEN,
ThoughtChainComponent,
} from "../ThoughtContainer";

const PromptReply = ({
uuid,
Expand Down Expand Up @@ -61,9 +67,9 @@ const PromptReply = ({
<div className="py-8 px-4 w-full flex gap-x-5 md:max-w-[80%] flex-col">
<div className="flex gap-x-5">
<WorkspaceProfileImage workspace={workspace} />
<span
className="break-words"
dangerouslySetInnerHTML={{ __html: renderMarkdown(reply) }}
<RenderAssistantChatContent
key={`${uuid}-prompt-reply-content`}
message={reply}
/>
</div>
<Citations sources={sources} />
Expand All @@ -88,4 +94,51 @@ export function WorkspaceProfileImage({ workspace }) {
return <UserIcon user={{ uid: workspace.slug }} role="assistant" />;
}

function RenderAssistantChatContent({ message }) {
const contentRef = useRef("");
const thoughtChainRef = useRef(null);

useEffect(() => {
const thinking =
message.match(THOUGHT_REGEX_OPEN) && !message.match(THOUGHT_REGEX_CLOSE);

if (thinking && thoughtChainRef.current) {
thoughtChainRef.current.updateContent(message);
return;
}

const completeThoughtChain = message.match(THOUGHT_REGEX_COMPLETE)?.[0];
const msgToRender = message.replace(THOUGHT_REGEX_COMPLETE, "");

if (completeThoughtChain && thoughtChainRef.current) {
thoughtChainRef.current.updateContent(completeThoughtChain);
}

contentRef.current = msgToRender;
}, [message]);

const thinking =
message.match(THOUGHT_REGEX_OPEN) && !message.match(THOUGHT_REGEX_CLOSE);
if (thinking)
return (
<ThoughtChainComponent ref={thoughtChainRef} content="" expanded={true} />
);

return (
<div className="flex flex-col gap-y-1">
{message.match(THOUGHT_REGEX_COMPLETE) && (
<ThoughtChainComponent
ref={thoughtChainRef}
content=""
expanded={true}
/>
)}
<span
className="break-words"
dangerouslySetInnerHTML={{ __html: renderMarkdown(contentRef.current) }}
/>
</div>
);
}

export default memo(PromptReply);
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { useState, forwardRef, useImperativeHandle } from "react";
import renderMarkdown from "@/utils/chat/markdown";
import { Brain, CaretDown } from "@phosphor-icons/react";
import DOMPurify from "dompurify";
import truncate from "truncate";
import { isMobile } from "react-device-detect";

const THOUGHT_KEYWORDS = ["thought", "thinking", "think", "thought_chain"];
const CLOSING_TAGS = [...THOUGHT_KEYWORDS, "response", "answer"];
export const THOUGHT_REGEX_OPEN = new RegExp(
THOUGHT_KEYWORDS.map((keyword) => `<${keyword}\\s*(?:[^>]*?)?\\s*>`).join("|")
);
export const THOUGHT_REGEX_CLOSE = new RegExp(
CLOSING_TAGS.map((keyword) => `</${keyword}\\s*(?:[^>]*?)?>`).join("|")
);
export const THOUGHT_REGEX_COMPLETE = new RegExp(
THOUGHT_KEYWORDS.map(
(keyword) =>
`<${keyword}\\s*(?:[^>]*?)?\\s*>[\\s\\S]*?<\\/${keyword}\\s*(?:[^>]*?)?>`
).join("|")
);
const THOUGHT_PREVIEW_LENGTH = isMobile ? 25 : 50;

/**
* Component to render a thought chain.
* @param {string} content - The content of the thought chain.
* @param {boolean} expanded - Whether the thought chain is expanded.
* @returns {JSX.Element}
*/
export const ThoughtChainComponent = forwardRef(
({ content: initialContent, expanded }, ref) => {
const [content, setContent] = useState(initialContent);
const [isExpanded, setIsExpanded] = useState(expanded);
useImperativeHandle(ref, () => ({
updateContent: (newContent) => {
setContent(newContent);
},
}));

const isThinking =
content.match(THOUGHT_REGEX_OPEN) && !content.match(THOUGHT_REGEX_CLOSE);
const isComplete =
content.match(THOUGHT_REGEX_COMPLETE) ||
content.match(THOUGHT_REGEX_CLOSE);
const tagStrippedContent = content
.replace(THOUGHT_REGEX_OPEN, "")
.replace(THOUGHT_REGEX_CLOSE, "");
const autoExpand =
isThinking && tagStrippedContent.length > THOUGHT_PREVIEW_LENGTH;
const canExpand = tagStrippedContent.length > THOUGHT_PREVIEW_LENGTH;
if (!content || !content.length) return null;

function handleExpandClick() {
if (!canExpand) return;
setIsExpanded(!isExpanded);
}

return (
<div className="flex justify-start items-end transition-all duration-200 w-full md:max-w-[800px]">
<div className="pb-2 w-full flex gap-x-5 flex-col relative">
<div
style={{
transition: "all 0.1s ease-in-out",
borderRadius: isExpanded || autoExpand ? "6px" : "24px",
}}
className={`${isExpanded || autoExpand ? "" : `${canExpand ? "hover:bg-theme-sidebar-item-hover" : ""}`} items-start bg-theme-bg-chat-input py-2 px-4 flex gap-x-2 border border-theme-sidebar-border`}
>
{isThinking || isComplete ? (
<Brain
data-tooltip-id="cot-thinking"
data-tooltip-content={
isThinking
? "Model is thinking..."
: "Model has finished thinking"
}
className={`w-4 h-4 mt-1 ${isThinking ? "text-blue-500 animate-pulse" : "text-green-400"}`}
aria-label={
isThinking
? "Model is thinking..."
: "Model has finished thinking"
}
/>
) : null}
<div className="flex-1 overflow-hidden">
{!isExpanded && !autoExpand ? (
<span
className="text-xs text-theme-text-secondary font-mono inline-block w-full"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(
truncate(tagStrippedContent, THOUGHT_PREVIEW_LENGTH)
),
}}
/>
) : (
<span
className="text-xs text-theme-text-secondary font-mono inline-block w-full"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(
renderMarkdown(tagStrippedContent)
),
}}
/>
)}
</div>
<div className="flex items-center gap-x-2">
{!autoExpand && canExpand ? (
<button
onClick={handleExpandClick}
data-tooltip-id="expand-cot"
data-tooltip-content={
isExpanded ? "Hide thought chain" : "Show thought chain"
}
className="border-none text-theme-text-secondary hover:text-theme-text-primary transition-colors p-1 rounded-full hover:bg-theme-sidebar-item-hover"
aria-label={
isExpanded ? "Hide thought chain" : "Show thought chain"
}
>
<CaretDown
className={`w-4 h-4 transform transition-transform duration-200 ${isExpanded ? "rotate-180" : ""}`}
/>
</button>
) : null}
</div>
</div>
</div>
</div>
);
}
);
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ export function ChatTooltips() {
delayShow={300}
className="tooltip !text-xs"
/>
<Tooltip
id="cot-thinking"
place="bottom"
delayShow={500}
className="tooltip !text-xs"
/>
</>
);
}