@@ -15,6 +15,8 @@ export interface Message { is_sent: boolean uid: string model: string + /** Reasoning / think stream content from models that emit SSE event `think` */ + think?: string } export interface VideoMessage { @@ -4,6 +4,7 @@ export interface ChatStreamSession { inputMessageUuid: string lastOffset: number modelContent: string + thinkContent: string } function getStorageKey(chatUid: string) { @@ -30,6 +31,7 @@ export function readChatStreamSession(chatUid: string): ChatStreamSession | null inputMessageUuid: parsed.inputMessageUuid ?? '', lastOffset: parsed.lastOffset, modelContent: parsed.modelContent ?? '', + thinkContent: parsed.thinkContent ?? '', } } catch { return null @@ -1,4 +1,4 @@ -export type ChatStreamEventName = 'start' | 'token' | 'done' | 'error' +export type ChatStreamEventName = 'start' | 'think' | 'token' | 'done' | 'error' export interface ParsedSseEvent { id?: number @@ -36,3 +36,39 @@ export function parseThinkBlocks(content: string): ParsedThinkContent { hasIncompleteThink: Boolean(incompleteMatch), } } + +/** Removes think text from the answer so it is not rendered twice after `done`. */ +export function stripThinkFromMain(main: string, think: string): string { + if (!main || !think.trim()) { + return main + } + + const parsed = parseThinkBlocks(main) + let result = parsed.thinkContents.length > 0 ? parsed.mainContent : main + + const trimmedThink = think.trim() + const trimmedMain = result.trimStart() + + if (trimmedMain.startsWith(trimmedThink)) { + return trimmedMain.slice(trimmedThink.length).trim() + } + + return result +} + +/** Splits final `done` payload: think goes to the think block, answer stays as main content. */ +export function splitFinalStreamContent( + content: string, + streamedThink = '' +): { think: string; main: string } { + const parsed = parseThinkBlocks(content) + const thinkFromTags = parsed.thinkContents.join('\n\n') + // Prefer already streamed think to keep the block visually stable + const think = streamedThink.trim() ? streamedThink : thinkFromTags + const mainSource = parsed.thinkContents.length > 0 ? parsed.mainContent : content + + return { + think, + main: stripThinkFromMain(mainSource, think), + } +} @@ -10,6 +10,7 @@ import { Variant } from '#/shared/lib/hooks/use-show-data' import { chatMessagesApi, parseStreamErrorResponse } from '../api/chat-messages-api' import { clearChatStreamSession, readChatStreamSession, writeChatStreamSession } from '../lib/chat-stream-session' import { readSseEvents } from '../lib/parse-sse' +import { splitFinalStreamContent } from '../lib/parse-think-blocks' const TEMP_USER_MESSAGE_UID = 'new-send' const STREAMING_PENDING_UID = 'pending' @@ -105,7 +106,8 @@ function ensureModelMessageForStream( messages: Message[], inputMessageUuid: string, modelType: string, - savedContent?: string + savedContent?: string, + savedThink?: string ): Message[] { const userIndex = messages.findIndex((message) => message.uid === inputMessageUuid) if (userIndex === -1) { @@ -121,10 +123,17 @@ function ensureModelMessageForStream( !modelMessage.content || modelMessage.content === getWaitingForModelContent(modelType) || savedContent if (shouldReplace && savedContent) { return messages.map((message, index) => - index === userIndex + 1 ? { ...message, content: waitingContent } : message + index === userIndex + 1 + ? { ...message, content: waitingContent, think: savedThink || message.think } + : message ) } } + if (savedThink && modelMessage.think !== savedThink) { + return messages.map((message, index) => + index === userIndex + 1 ? { ...message, think: savedThink } : message + ) + } return messages } @@ -142,6 +151,7 @@ function ensureModelMessageForStream( is_sent: true, uid: getStreamingModelUid(inputMessageUuid), model: '', + think: savedThink || '', }, ] } @@ -195,6 +205,7 @@ function buildStreamRequestBody(dataForSend: MessageSend) { type StreamRuntime = { lastOffset: number modelContent: string + thinkContent: string hasStreamEvents: boolean receivedStart: boolean streamCompleted: boolean @@ -218,6 +229,7 @@ async function consumeSseStream( body: ReadableStream, handlers: { onStart?: (messageUuid: string) => void + onThink?: (content: string) => void onToken: (content: string) => void onDone: (content: string) => void onError: (detail: string) => void @@ -235,6 +247,13 @@ async function consumeSseStream( handlers.onStart?.(data?.message_uuid ?? '') break } + case 'think': { + const data = sseEvent.data as { content?: string } + if (typeof data?.content === 'string' && data.content.length > 0) { + handlers.onThink?.(data.content) + } + break + } case 'token': { const data = sseEvent.data as { content?: string } if (typeof data?.content === 'string' && data.content.length > 0) { @@ -277,6 +296,7 @@ export function useChatModel( const streamRuntimeRef = useRef({ lastOffset: 0, modelContent: '', + thinkContent: '', hasStreamEvents: false, receivedStart: false, streamCompleted: false, @@ -291,15 +311,17 @@ export function useChatModel( inputMessageUuid, lastOffset: runtime.lastOffset, modelContent: runtime.modelContent, + thinkContent: runtime.thinkContent, }) }, [] ) - const resetStreamRuntime = useCallback((modelContent = '') => { + const resetStreamRuntime = useCallback((modelContent = '', thinkContent = '') => { streamRuntimeRef.current = { lastOffset: 0, modelContent, + thinkContent, hasStreamEvents: false, receivedStart: false, streamCompleted: false, @@ -358,7 +380,13 @@ export function useChatModel( ) if (options.isReconnect && !hasPlaceholder) { - return ensureModelMessageForStream(prev, messageUuid, modelType, runtime.modelContent) + return ensureModelMessageForStream( + prev, + messageUuid, + modelType, + runtime.modelContent, + runtime.thinkContent + ) } return prev.map((message) => { @@ -366,7 +394,12 @@ export function useChatModel( return { ...message, uid: messageUuid } } if (message.uid === pendingUid) { - return { ...message, uid: modelUid, content: runtime.modelContent } + return { + ...message, + uid: modelUid, + content: runtime.modelContent, + think: runtime.thinkContent, + } } return message }) @@ -379,6 +412,31 @@ export function useChatModel( persistStreamSession(currentChat, inputUuid) } }, + onThink: (content) => { + runtime.hasStreamEvents = true + runtime.thinkContent = runtime.thinkContent + content + + const inputUuid = inputMessageUuidRef.current + if (inputUuid) { + persistStreamSession(currentChat, inputUuid) + } + + setMessages((prev) => { + if (!prev) { + return prev + } + + const resolvedModelUid = inputUuid + ? findModelMessageUid(prev, inputUuid) + : getStreamingModelUid(STREAMING_PENDING_UID) + + return prev.map((message) => + message.uid === resolvedModelUid + ? { ...message, think: runtime.thinkContent } + : message + ) + }) + }, onToken: (content) => { runtime.hasStreamEvents = true const inputUuid = inputMessageUuidRef.current @@ -398,7 +456,9 @@ export function useChatModel( const resolvedModelUid = findModelMessageUid(prev, inputUuid) return prev.map((message) => - message.uid === resolvedModelUid ? { ...message, content: runtime.modelContent } : message + message.uid === resolvedModelUid + ? { ...message, content: runtime.modelContent, think: runtime.thinkContent } + : message ) }) }, @@ -410,8 +470,10 @@ export function useChatModel( return } + const { think, main } = splitFinalStreamContent(content, runtime.thinkContent) clearChatStreamSession(currentChat) - runtime.modelContent = content + runtime.modelContent = main + runtime.thinkContent = think setMessages((prev) => { if (!prev) { @@ -419,7 +481,11 @@ export function useChatModel( } const modelUid = findModelMessageUid(prev, inputUuid) - return prev.map((message) => (message.uid === modelUid ? { ...message, content } : message)) + return prev.map((message) => + message.uid === modelUid + ? { ...message, content: main, think: think || undefined } + : message + ) }) }, onError: (detail) => { @@ -461,7 +527,7 @@ export function useChatModel( streamOffset: number, loadedMessages?: Message[], savedContent?: string, - options: { silent?: boolean; attempt?: number } = {} + options: { silent?: boolean; attempt?: number; savedThink?: string } = {} ): Promise => { if (!data?.access) { return false @@ -481,11 +547,19 @@ export function useChatModel( isSendingRef.current = true inputMessageUuidRef.current = inputMessageUuid - resetStreamRuntime(savedContent || '') + resetStreamRuntime(savedContent || '', options.savedThink || '') streamRuntimeRef.current.lastOffset = streamOffset if (loadedMessages) { - setMessages(ensureModelMessageForStream(loadedMessages, inputMessageUuid, modelType, savedContent)) + setMessages( + ensureModelMessageForStream( + loadedMessages, + inputMessageUuid, + modelType, + savedContent, + options.savedThink + ) + ) } setLoading(true) @@ -533,7 +607,11 @@ export function useChatModel( streamRuntimeRef.current.lastOffset, undefined, streamRuntimeRef.current.modelContent, - { silent: true, attempt: attempt + 1 } + { + silent: true, + attempt: attempt + 1, + savedThink: streamRuntimeRef.current.thinkContent, + } ) } @@ -578,7 +656,8 @@ export function useChatModel( inputMessageUuid, session?.lastOffset ?? detected?.lastOffset ?? 0, loadedMessages, - session?.modelContent + session?.modelContent, + { savedThink: session?.thinkContent } ) }, [reconnectToStream] @@ -804,6 +883,7 @@ export function useChatModel( inputMessageUuid: STREAMING_PENDING_UID, lastOffset: 0, modelContent: getWaitingForModelContent(modelType), + thinkContent: '', }) try { @@ -842,7 +922,7 @@ export function useChatModel( streamRuntimeRef.current.lastOffset, undefined, streamRuntimeRef.current.modelContent, - { silent: true } + { silent: true, savedThink: streamRuntimeRef.current.thinkContent } ) if (reconnected) { @@ -872,9 +952,14 @@ export function useChatModel( const session = readChatStreamSession(currentChat) if (session) { const inputUuid = inputMessageUuidRef.current ?? session.inputMessageUuid - const reconnected = await reconnectToStream(currentChat, inputUuid, session.lastOffset, undefined, session.modelContent, { - silent: true, - }) + const reconnected = await reconnectToStream( + currentChat, + inputUuid, + session.lastOffset, + undefined, + session.modelContent, + { silent: true, savedThink: session.thinkContent } + ) if (reconnected) { setOffset((prev) => prev + 2) } @@ -6,7 +6,7 @@ import Image from 'next/image' import { TooltipCustom } from '#/shared' import { formatDate } from '#/shared/lib/helpers' import { Markdown } from '../../lib/markdown/markdown' -import { parseThinkBlocks } from '../../lib/parse-think-blocks' +import { parseThinkBlocks, stripThinkFromMain } from '../../lib/parse-think-blocks' import styles from './bot-message.module.scss' import { FullscreenIcon } from './icons/fullscreen-icon' @@ -64,9 +64,19 @@ export function BotMessage(props: any) { const rawContent = props.message.file ? props.message.file.match(/(.+)\/(.+)\?/)[2] : props.message.content - const { thinkContents, mainContent, hasIncompleteThink } = parseThinkBlocks( - props.message.file ? '' : props.message.content || '', + + const streamThink = props.message.think || '' + const parsedThink = parseThinkBlocks(props.message.file ? '' : props.message.content || '') + const thinkContents = streamThink ? [streamThink] : parsedThink.thinkContents + const mainContent = streamThink + ? stripThinkFromMain(parsedThink.mainContent || props.message.content || '', streamThink) + : parsedThink.mainContent + const isWaitingContent = + typeof mainContent === 'string' && mainContent.startsWith('Ваш вопрос получен. Ожидание ответа') + const isThinkStreaming = Boolean( + props.isStreaming && (streamThink || parsedThink.hasIncompleteThink) && (isWaitingContent || !mainContent), ) + const displayMainContent = isThinkStreaming ? '' : mainContent return ( @@ -165,17 +175,16 @@ export function BotMessage(props: any) { <> {thinkContents.map((thinkContent, index) => ( ))} - {mainContent ? ( - + {displayMainContent ? ( + ) : null} )} @@ -249,7 +258,7 @@ export function BotMessage(props: any) { copy( props.message.file ? props.message.file.split('/')[4].split('?')[0] - : mainContent, + : displayMainContent || mainContent, ) }} > @@ -311,6 +320,7 @@ export function BotMessage(props: any) { onClose={() => setFullscreenModalOpen(false)} modelTitle={props.modelTitle} messageContent={props.message.content} + thinkContent={props.message.think} /> ) @@ -28,6 +28,8 @@ interface IMessagesList { paginationLoading?: boolean } +const STREAM_STICK_BOTTOM_THRESHOLD_PX = 200 + export const ChatMessagesList: React.FC = memo( ({ messageResponse, onLoadImage, setResendValue, modelTitle, device, modelType, botParams, getMessagesPagination, deleteMessage, loading, paginationLoading = false }) => { const paginationScroll = React.useRef(null) @@ -37,6 +39,7 @@ export const ChatMessagesList: React.FC = memo( firstUid?: string lastUid?: string lastContentLength: number + lastThinkLength: number } | null>(null) const scrollAnchorRef = useRef<{ uid: string @@ -205,11 +208,13 @@ export const ChatMessagesList: React.FC = memo( return } + const lastMessage = messageResponse[messageResponse.length - 1] const snapshot = { length: messageResponse.length, firstUid: messageResponse[0]?.uid, - lastUid: messageResponse[messageResponse.length - 1]?.uid, - lastContentLength: messageResponse[messageResponse.length - 1]?.content?.length ?? 0, + lastUid: lastMessage?.uid, + lastContentLength: lastMessage?.content?.length ?? 0, + lastThinkLength: lastMessage?.think?.length ?? 0, } const prev = prevMessagesSnapshotRef.current @@ -232,8 +237,9 @@ export const ChatMessagesList: React.FC = memo( const streamingGrowth = structureSame && - snapshot.lastContentLength > prev.lastContentLength && - Boolean(snapshot.lastUid?.startsWith('streaming:')) + Boolean(snapshot.lastUid?.startsWith('streaming:')) && + (snapshot.lastContentLength !== prev.lastContentLength || + snapshot.lastThinkLength !== prev.lastThinkLength) if (prepended && scrollAnchorRef.current) { restorePrependScroll(block) @@ -242,7 +248,7 @@ export const ChatMessagesList: React.FC = memo( top: block.scrollHeight, behavior: 'smooth', }) - } else if (streamingGrowth && scrollBottomRef.current < 200) { + } else if (streamingGrowth && scrollBottomRef.current < STREAM_STICK_BOTTOM_THRESHOLD_PX) { block.scrollTop = block.scrollHeight } @@ -4,7 +4,7 @@ import Dialog from '@mui/material/Dialog' import Image from 'next/image' import { Markdown } from '../../lib/markdown/markdown' -import { parseThinkBlocks } from '../../lib/parse-think-blocks' +import { parseThinkBlocks, stripThinkFromMain } from '../../lib/parse-think-blocks' import { ZoomOutIcon as ZoomInIcon } from './icons/zoom-in-icon' import { ZoomOutIcon } from './icons/zoom-out-icon' @@ -17,6 +17,7 @@ interface FullscreenMessageModalProps { onClose: () => void modelTitle?: string messageContent?: string + thinkContent?: string } export const FullscreenMessageModal: React.FC = ({ @@ -24,6 +25,7 @@ export const FullscreenMessageModal: React.FC = ({ onClose, modelTitle, messageContent, + thinkContent, }) => { const [fontFamily, setFontFamily] = useState('Inter,sans-serif') const [fontSize, setFontSize] = useState(16) @@ -49,7 +51,11 @@ export const FullscreenMessageModal: React.FC = ({ const copyIconRef = useRef(null) - const { thinkContents, mainContent } = parseThinkBlocks(messageContent || '') + const parsedThink = parseThinkBlocks(messageContent || '') + const thinkContents = thinkContent ? [thinkContent] : parsedThink.thinkContents + const mainContent = thinkContent + ? stripThinkFromMain(parsedThink.mainContent || messageContent || '', thinkContent) + : parsedThink.mainContent // Функция для создания отформатированного HTML для Word const createFormattedHtml = useCallback((htmlContent: string) => { @@ -1,5 +1,5 @@ .thinkBlock { - margin: 12px 0; + margin: 16px 0 12px 0; border-left: 2px solid #4a4a52; padding-left: 12px; } @@ -8,18 +8,16 @@ display: flex; align-items: center; gap: 6px; + padding: 0; + border: 0; + background: none; cursor: pointer; user-select: none; - list-style: none; color: #a6a5a5; font-size: 13px; font-weight: 600; line-height: 1.4; font-family: Inter, sans-serif; - - &::-webkit-details-marker { - display: none; - } } .summaryLabel { @@ -31,18 +29,18 @@ transition: transform 0.2s ease; } -.thinkBlock[open] .chevron { +.chevronOpen { transform: rotate(180deg); } .content { margin-top: 8px; color: #b0b0b8; - font-size: 14px; - line-height: 1.5; + font-size: 15px; + line-height: 22.5px; font-family: Inter, sans-serif; - - > div { - margin-top: 0; - } -} + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + background: transparent; +} \ No newline at end of file @@ -1,7 +1,5 @@ import { useEffect, useState } from 'react' -import { Markdown } from '../../lib/markdown/markdown' - import styles from './think-block.module.scss' interface ThinkBlockProps { @@ -24,17 +22,16 @@ export function ThinkBlock({ content, streaming = false, defaultOpen = false }: } return ( -
{ - setIsOpen((event.target as HTMLDetailsElement).open) - }} - > - +
+
-
- -
-
+ + {isOpen ?
{content}
: null} + ) }