@@ -3,10 +3,8 @@ import { ClickAwayListener, Tooltip, useMediaQuery } from '@mui/material' import styles from './tooltip.module.scss' -import { c } from '#/shared/lib/helpers' - interface Props { - title?: string + title?: React.ReactNode children: React.ReactNode className?: string maxWidth?: string @@ -30,6 +28,11 @@ interface Props { fullWidthTrigger?: boolean /** На устройствах без hover (тач) открывать/закрывать по нажатию, снаружи — закрытие. */ tapToOpenOnMobile?: boolean + /** Controlled open (e.g. selection toolbar). */ + open?: boolean + onClose?: () => void + /** Popper offset [skidding, distance]. Negative distance pulls tooltip closer to the anchor. */ + offset?: [number, number] } /** Выше оверлеев модалок (см. template.module.scss — 9999) */ const TOOLTIP_POPPER_Z_INDEX = 10050 @@ -44,25 +47,48 @@ export const TooltipCustom: React.FC = ({ wrapperWidth, fullWidthTrigger = false, tapToOpenOnMobile = false, + open: controlledOpen, + onClose, + offset, }) => { const isTouchPrimary = useMediaQuery('(hover: none)', { noSsr: true }) const tapMode = Boolean(tapToOpenOnMobile && isTouchPrimary) const [tapOpen, setTapOpen] = useState(false) + const isControlled = controlledOpen !== undefined const triggerSpanStyle: React.CSSProperties | undefined = wrapperWidth ? { display: 'block', width: wrapperWidth, maxWidth: '100%', minWidth: 0 } : undefined + const resolvedOpen = isControlled ? controlledOpen : tapMode ? tapOpen : undefined + const handleClose = () => { + if (isControlled) { + onClose?.() + return + } + if (tapMode) { + setTapOpen(false) + } + } + const tooltip = ( setTapOpen(false) : undefined} - disableHoverListener={tapMode} - disableFocusListener={tapMode} - disableTouchListener={tapMode} + open={resolvedOpen} + onClose={isControlled || tapMode ? handleClose : undefined} + disableHoverListener={isControlled || tapMode} + disableFocusListener={isControlled || tapMode} + disableTouchListener={isControlled || tapMode} onClick={stopClickPropagation ? (e: React.MouseEvent) => e.stopPropagation() : undefined} PopperProps={{ sx: { zIndex: TOOLTIP_POPPER_Z_INDEX }, + modifiers: offset + ? [ + { + name: 'offset', + options: { offset }, + }, + ] + : undefined, ...(stopClickPropagation ? { onClick(e: React.MouseEvent) { @@ -96,7 +122,7 @@ export const TooltipCustom: React.FC = ({ className={fullWidthTrigger ? styles.triggerFullWidth : undefined} style={triggerSpanStyle} onClick={ - tapMode + tapMode && !isControlled ? (e) => { if (stopClickPropagation) e.stopPropagation() setTapOpen((v) => !v) @@ -109,5 +135,15 @@ export const TooltipCustom: React.FC = ({ ) - return tapMode ? setTapOpen(false)}>{tooltip} : tooltip + if (isControlled) { + return ( + + + {tooltip} + + + ) + } + + return tapMode ? {tooltip} : tooltip } @@ -13,6 +13,10 @@ export async function parseStreamErrorResponse(response: Response): Promise { try { @@ -40,10 +44,21 @@ export const chatMessagesApi = { sendMessage: async (chatUid: string, dataForSend: MessageSend | FormData, token?: string) => { const headerDataType = dataForSend instanceof FormData ? 'multipart/form-data' : 'application/json' + const payload = + dataForSend instanceof FormData + ? dataForSend + : { ...dataForSend, content: sanitizeContent(dataForSend.content) } + + if (payload instanceof FormData && payload.has('content')) { + const raw = payload.get('content') + if (typeof raw === 'string') { + payload.set('content', sanitizeContent(raw)) + } + } return axios.post>( getApiUrl() + `/chats/${chatUid}/messages/`, - dataForSend, + payload, { withCredentials: true, headers: { @@ -62,6 +77,12 @@ export const chatMessagesApi = { } if (dataForSend instanceof FormData) { + if (dataForSend.has('content')) { + const raw = dataForSend.get('content') + if (typeof raw === 'string') { + dataForSend.set('content', sanitizeContent(raw)) + } + } return fetch(url, { method: 'POST', headers: streamHeaders, @@ -71,7 +92,8 @@ export const chatMessagesApi = { }) } - const { content, info } = dataForSend + const { info } = dataForSend + const content = sanitizeContent(dataForSend.content) return fetch(url, { method: 'POST', @@ -8,6 +8,26 @@ border: 2px solid rgb(66, 66, 72) !important; } +.wrapperWithQuote { + align-items: flex-end; +} + +.inputColumnWithQuote { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + min-width: 0; + max-height: 180px; + overflow-y: auto; + scrollbar-width: none; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; + } +} + .wrapperImages { background-color: #151518; } @@ -19,6 +39,7 @@ .settingsIcon { min-width: 21px; min-height: 21px; + align-self: center; } .area { @@ -52,6 +73,7 @@ display: flex; align-items: center; justify-content: center; + flex-shrink: 0; } .divider { @@ -9,6 +9,7 @@ import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' import { ChatDisclaimer } from '#/shared/ui/chat-disclaimer/chat-disclaimer' import { PredictPrice } from './predict-price' +import { QuoteCodeBlock } from './quote-code-block' import classes from './model-input.module.scss' @@ -43,6 +44,49 @@ function buildRequiredForVersion(inputs: IModelInputs[], currentVersion: string) return [...requiredTypes] } +function composeMessage(text: string, quote: string | null | undefined): string { + const parts: string[] = [] + if (quote != null && quote !== '') { + parts.push('```' + quote + '```') + } + if (text !== '') { + parts.push(text) + } + return parts.join(' ') +} + +type DraftPayload = { + text: string + quote: string | null +} + +function readDraft(raw: string | null): DraftPayload | null { + if (raw === null || raw === '') { + return null + } + try { + const parsed = JSON.parse(raw) as unknown + if (parsed && typeof parsed === 'object' && 'text' in parsed) { + const draft = parsed as { text?: unknown; quote?: unknown } + return { + text: typeof draft.text === 'string' ? draft.text : '', + quote: typeof draft.quote === 'string' && draft.quote !== '' ? draft.quote : null, + } + } + } catch { + // legacy: plain text string + } + return { text: raw, quote: null } +} + +function writeDraft(key: string, text: string, quote: string | null) { + if (text === '' && quote == null) { + window.localStorage.removeItem(key) + return + } + window.localStorage.setItem(key, JSON.stringify({ text, quote } satisfies DraftPayload)) +} + interface Input { loading: boolean wonderMe?: () => void @@ -59,6 +103,8 @@ interface Input { viewMobileSettings: () => void currentVersion: string resendValue?: string + quoteValue?: string | null + onQuoteChange?: (value: string | null) => void value?: string onValueChange?: (value: string) => void predictedPrice?: string | null @@ -76,6 +122,8 @@ export const ModelInput: FC = ({ viewMobileSettings, currentVersion, resendValue, + quoteValue, + onQuoteChange, blocked, value: externalValue, onValueChange: externalOnChange, @@ -104,6 +152,8 @@ export const ModelInput: FC = ({ const draftHydratedRef = useRef(false) const setValueRef = useRef(setValue) setValueRef.current = setValue + const onQuoteChangeRef = useRef(onQuoteChange) + onQuoteChangeRef.current = onQuoteChange const clearDraft = useCallback(() => { try { @@ -111,11 +161,14 @@ export const ModelInput: FC = ({ } catch { } setValue('') - }, [draftStorageKey, setValue]) + onQuoteChange?.(null) + }, [draftStorageKey, setValue, onQuoteChange]) const valueRef = useRef(value) + const quoteRef = useRef(quoteValue) const requiredRef = useRef(required) valueRef.current = value + quoteRef.current = quoteValue requiredRef.current = required const wrappedSendMessage = useCallback( @@ -129,6 +182,17 @@ export const ModelInput: FC = ({ [sendMessage] ) + const submitMessage = useCallback(() => { + const message = composeMessage(valueRef.current, quoteRef.current) + if (!message) return false + const isSend = wrappedSendMessage(message, requiredRef.current) + if (isSend) { + clearDraft() + if (unpinImage) unpinImage() + } + return isSend + }, [clearDraft, unpinImage, wrappedSendMessage]) + useEffect(() => { if (input_types) { setRequired(buildRequiredForVersion(input_types, currentVersion)) @@ -179,9 +243,9 @@ export const ModelInput: FC = ({ useEffect(() => { if (resendValue) { - setValue(resendValue) + setValueRef.current(resendValue) } - }, [resendValue, setValue]) + }, [resendValue]) useEffect(() => { draftHydratedRef.current = false @@ -194,16 +258,21 @@ export const ModelInput: FC = ({ if (resendValue) { return } - const saved = window.localStorage.getItem(draftStorageKey) - if (saved !== null && saved !== '' && value === '') { - setValueRef.current(saved) + const draft = readDraft(window.localStorage.getItem(draftStorageKey)) + if (draft) { + if (draft.text !== '' && value === '') { + setValueRef.current(draft.text) + } + if (draft.quote) { + onQuoteChangeRef.current?.(draft.quote) + } return } } - window.localStorage.setItem(draftStorageKey, value) + writeDraft(draftStorageKey, value, quoteValue ?? null) } catch { } - }, [draftStorageKey, resendValue, value]) + }, [draftStorageKey, resendValue, value, quoteValue]) useEffect(() => { const handleTourSuggestedQuery = (e: CustomEvent<{ query: string }>) => { @@ -219,22 +288,51 @@ export const ModelInput: FC = ({ useEffect(() => { const handleTourSendMessage = () => { - const { current: msg } = valueRef - const { current: req } = requiredRef - if (msg && sendMessage(msg, req)) { - clearDraft() - if (unpinImage) unpinImage() - window.dispatchEvent(new CustomEvent('user-sent-message')) - } + submitMessage() } window.addEventListener('tour-send-message', handleTourSendMessage) return () => window.removeEventListener('tour-send-message', handleTourSendMessage) - }, [clearDraft, sendMessage, unpinImage]) + }, [submitMessage]) + + const composedForSend = composeMessage(value, quoteValue) + const hasQuote = quoteValue != null + + const textarea = ( + + ) return ( <>
{!desktop && ( = ({ /> )} - + {hasQuote ? ( +
+ onQuoteChange?.(next)} + onRemove={() => onQuoteChange?.(null)} + /> + {textarea} +
+ ) : ( + textarea + )}
@@ -300,13 +387,16 @@ export const ModelInput: FC = ({ submitMessage()} + setInput={externalOnChange ? (val: string | ((prev: string) => string)) => { const newVal = typeof val === 'function' ? val(value) : val externalOnChange(newVal) - } : + if (newVal === '') { + onQuoteChange?.(null) + } + } : (val: string | ((prev: string) => string)) => { const next = typeof val === 'function' ? val(value) : val if (next === '') { @@ -0,0 +1,66 @@ +.codeBlock { + position: relative; + width: 100%; + min-width: 0; + padding: 10px 28px 10px 12px; + border-radius: 10px; + border: 1px solid #303035; + background-color: #1e1e22; + box-sizing: border-box; +} + +.codeBlockRemove { + position: absolute; + top: 4px; + right: 6px; + width: 22px; + height: 22px; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: #a6a5a5; + font-size: 18px; + line-height: 1; + cursor: pointer; + + &:hover:not(:disabled) { + color: #f9fafb; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.codeArea { + width: 100%; + resize: none; + border: none; + outline: none; + padding: 0; + margin: 0; + max-height: 120px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; + font-size: 14px; + line-height: 1.4; + color: #f9fafb; + background: transparent; + overflow-y: auto; + scrollbar-width: none; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; + } + + &:focus { + outline: none; + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +} @@ -0,0 +1,54 @@ +import React, { useLayoutEffect, useRef } from 'react' + +import styles from './quote-code-block.module.scss' + +type Props = { + value: string + onChange: (value: string) => void + onRemove: () => void + disabled?: boolean +} + +function autoResize(el: HTMLTextAreaElement | null) { + if (!el) return + el.style.height = 'auto' + el.style.height = `${el.scrollHeight}px` +} + +export function QuoteCodeBlock({ value, onChange, onRemove, disabled }: Props) { + const areaRef = useRef(null) + + useLayoutEffect(() => { + autoResize(areaRef.current) + }, [value]) + + return ( +
+ +