mirror of
https://github.com/enricoros/big-AGI.git
synced 2026-05-10 21:50:14 -07:00
282 lines
10 KiB
TypeScript
282 lines
10 KiB
TypeScript
import * as React from 'react';
|
|
|
|
import type { ColorPaletteProp, SxProps } from '@mui/joy/styles/types';
|
|
import { Box, Chip, Typography } from '@mui/joy';
|
|
import AllInclusiveIcon from '@mui/icons-material/AllInclusive';
|
|
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
|
import TextFieldsIcon from '@mui/icons-material/TextFields';
|
|
|
|
import { RenderMarkdown } from '~/modules/blocks/markdown/RenderMarkdown';
|
|
import { useScaledTypographySx } from '~/modules/blocks/blocks.styles';
|
|
|
|
import { ConfirmationModal } from '~/common/components/modals/ConfirmationModal';
|
|
import { ExpanderControlledBox } from '~/common/components/ExpanderControlledBox';
|
|
import { adjustContentScaling, ContentScaling, themeScalingMap } from '~/common/app.theme';
|
|
import { animationSpinHalfPause } from '~/common/util/animUtils';
|
|
import { createTextContentFragment, DMessageContentFragment, DMessageFragmentId } from '~/common/stores/chat/chat.fragments';
|
|
import { useOverlayComponents } from '~/common/layout/overlays/useOverlayComponents';
|
|
|
|
|
|
// configuration
|
|
const ENABLE_MARKDOWN_DETECTION = true;
|
|
// const REASONING_COLOR = '#ca74b8'; // '#f22a85' (folder-aligned), '#ca74b8' (emoji-aligned)
|
|
const REASONING_COLOR: ColorPaletteProp = 'success';
|
|
const ANTHROPIC_REDACTED_EXPLAINER = // https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#example-streaming-with-redacted-thinking
|
|
'Some of Claude\'s internal reasoning has been automatically encrypted for safety reasons. This doesn\'t affect the quality of responses.';
|
|
|
|
|
|
const _styles = {
|
|
|
|
block: {
|
|
mx: 1.5,
|
|
},
|
|
|
|
chip: {
|
|
pl: 1.5,
|
|
pr: 1.75,
|
|
my: '1px', // to not crop the outline on mobile, or on beam
|
|
minHeight: '1.5rem', // similar parts, modelOps and paired tools, are 1.75rem
|
|
'& .MuiChip-startDecorator': {
|
|
marginRight: '0.5em',
|
|
},
|
|
},
|
|
|
|
chipActive: {
|
|
outline: '1px solid',
|
|
outlineColor: `${REASONING_COLOR}.solidBg`, // .outlinedBorder
|
|
boxShadow: `1px 2px 4px -3px var(--joy-palette-${REASONING_COLOR}-solidBg)`,
|
|
// '& > button': {
|
|
// boxShadow: `inset 1px 2px 4px -3px var(--joy-palette-${REASONING_COLOR}-solidBg)`,
|
|
// },
|
|
},
|
|
|
|
chipIcon: undefined, // { fontSize: '1rem', },
|
|
chipIconPending: {
|
|
// fontSize: '1rem',
|
|
animation: `${animationSpinHalfPause} 2s ease-in-out infinite`,
|
|
},
|
|
|
|
chipExpanded: {
|
|
mt: '1px', // need to copy the `chip` mt
|
|
// borderRadius: 'sm',
|
|
// transition: 'border-radius 0.2s ease-in-out',
|
|
},
|
|
|
|
text: {
|
|
borderRadius: 'sm', // was: 12px
|
|
border: '1px solid',
|
|
borderColor: `${REASONING_COLOR}.outlinedColor`,
|
|
backgroundColor: `rgb(var(--joy-palette-${REASONING_COLOR}-lightChannel) / 15%)`, // similar to success.50
|
|
// boxShadow: 'inset 1px 1px 3px -3px var(--joy-palette-neutral-solidBg)',
|
|
mt: 1,
|
|
p: 1,
|
|
|
|
// plain text style
|
|
overflowWrap: 'anywhere',
|
|
whiteSpace: 'break-spaces',
|
|
|
|
// layout
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
},
|
|
|
|
textUndoWhitespace: {
|
|
// for markdown content, we want to allow it to control the whitespace and line breaks, so we undo the plain text styles that break on whitespace
|
|
overflowWrap: 'normal',
|
|
whiteSpace: 'normal',
|
|
},
|
|
|
|
buttonInline: {
|
|
outline: 'none',
|
|
// borderRadius: 'sm',
|
|
// fontSize: 'xs',
|
|
},
|
|
} as const;
|
|
|
|
|
|
/** Detect if content is potentially markdown based on common markdown patterns */
|
|
function _maybeMarkdownReasoning(text: string): boolean {
|
|
const trimmed = text.trimStart();
|
|
return trimmed.startsWith('**')
|
|
|| trimmed.startsWith('# ')
|
|
// || trimmed.startsWith('* ')
|
|
// || trimmed.startsWith('- ')
|
|
|| /^#{2,6}\s/.test(trimmed);
|
|
}
|
|
|
|
|
|
export const BlockPartModelAuxMemo = React.memo(BlockPartModelAux);
|
|
|
|
export function BlockPartModelAux(props: {
|
|
fragmentId: DMessageFragmentId,
|
|
auxType: 'reasoning' | string,
|
|
auxText: string,
|
|
auxHasSignature: boolean,
|
|
auxRedactedDataCount: number,
|
|
messagePendingIncomplete: boolean,
|
|
zenMode: boolean,
|
|
contentScaling: ContentScaling,
|
|
isLastFragment: boolean,
|
|
onFragmentDelete?: (fragmentId: DMessageFragmentId) => void,
|
|
onFragmentReplace?: (fragmentId: DMessageFragmentId, newFragment: DMessageContentFragment) => void,
|
|
}) {
|
|
|
|
// state
|
|
const [neverExpanded, setNeverExpanded] = React.useState(true);
|
|
const [expanded, setExpanded] = React.useState(false);
|
|
|
|
// external state
|
|
const { showPromisedOverlay } = useOverlayComponents();
|
|
|
|
// derived
|
|
const isActive = props.isLastFragment && props.messagePendingIncomplete;
|
|
const contentScaling = adjustContentScaling(props.contentScaling, -1);
|
|
const typeText = props.auxType === 'reasoning' ? 'Reasoning' : 'Auxiliary';
|
|
|
|
// memo
|
|
const maybeMarkdown = React.useMemo(() => !ENABLE_MARKDOWN_DETECTION || neverExpanded ? false : _maybeMarkdownReasoning(props.auxText), [neverExpanded, props.auxText]);
|
|
|
|
// memo style
|
|
const chipSx: SxProps = React.useMemo(() => ({
|
|
..._styles.chip,
|
|
...(isActive && _styles.chipActive),
|
|
...(expanded && _styles.chipExpanded),
|
|
fontSize: themeScalingMap[contentScaling]?.blockFontSize ?? undefined,
|
|
}), [contentScaling, expanded, isActive]);
|
|
const scaledTypographySx = useScaledTypographySx(contentScaling, false, false);
|
|
const textSx = React.useMemo(() => ({
|
|
..._styles.text,
|
|
...scaledTypographySx,
|
|
...(maybeMarkdown ? _styles.textUndoWhitespace : {}),
|
|
}), [maybeMarkdown, scaledTypographySx]);
|
|
|
|
|
|
// handlers
|
|
|
|
const { onFragmentDelete, onFragmentReplace } = props;
|
|
const showDelete = !!onFragmentDelete;
|
|
const showInline = !!onFragmentReplace;
|
|
|
|
const handleToggleExpanded = React.useCallback(() => {
|
|
setNeverExpanded(false);
|
|
setExpanded(on => !on);
|
|
}, []);
|
|
|
|
const handleDelete = React.useCallback(() => {
|
|
if (!onFragmentDelete) return;
|
|
showPromisedOverlay('chat-message-delete-aux', {}, ({ onResolve, onUserReject }) =>
|
|
<ConfirmationModal
|
|
open onClose={onUserReject} onPositive={() => onResolve(true)}
|
|
confirmationText={<>
|
|
Delete this {typeText.toLowerCase()} completely?
|
|
<br />
|
|
This action cannot be undone.
|
|
</>}
|
|
positiveActionText='Delete'
|
|
/>,
|
|
).then(() => {
|
|
onFragmentDelete(props.fragmentId);
|
|
}).catch(() => null /* ignore closure */);
|
|
}, [onFragmentDelete, props.fragmentId, showPromisedOverlay, typeText]);
|
|
|
|
const handleInline = React.useCallback(() => {
|
|
if (!onFragmentReplace) return;
|
|
showPromisedOverlay('chat-message-inline-aux', {}, ({ onResolve, onUserReject }) =>
|
|
<ConfirmationModal
|
|
open onClose={onUserReject} onPositive={() => onResolve(true)}
|
|
confirmationText={<>
|
|
Convert this {typeText.toLowerCase()} into regular message text?
|
|
<br />
|
|
It will become part of the message and can't be collapsed again.
|
|
</>}
|
|
positiveActionText='Convert'
|
|
/>,
|
|
).then(() => {
|
|
onFragmentReplace(props.fragmentId, createTextContentFragment(props.auxText));
|
|
}).catch(() => null /* ignore closure */);
|
|
}, [onFragmentReplace, props.auxText, props.fragmentId, showPromisedOverlay, typeText]);
|
|
|
|
|
|
// create up to 3 dots '.' based on the length of the auxText (1 dot per 100 characters)
|
|
// const dots = '.'.repeat(Math.floor(props.auxText.length / 100) % 5);
|
|
|
|
return <Box sx={_styles.block}>
|
|
|
|
{/* Chip to expand/collapse */}
|
|
<Box data-agi-no-copy /* do not copy these buttons */ sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<Chip
|
|
size='sm'
|
|
color={isActive || expanded ? REASONING_COLOR : 'neutral'}
|
|
variant={expanded ? 'solid' : 'soft'}
|
|
onClick={handleToggleExpanded}
|
|
sx={chipSx}
|
|
startDecorator={
|
|
<AllInclusiveIcon
|
|
sx={!expanded && isActive ? _styles.chipIconPending : _styles.chipIcon}
|
|
/* sx={{ color: expanded ? undefined : REASONING_COLOR }} */
|
|
/>
|
|
}
|
|
// startDecorator='🧠'
|
|
>
|
|
{/*Show {typeText}*/}
|
|
{isActive && !expanded && typeText === 'Reasoning' ? `${typeText}...` : `Show ${typeText}`}
|
|
</Chip>
|
|
|
|
{expanded && !props.messagePendingIncomplete && (showInline || showDelete) && !!props.auxText && (
|
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
|
|
{/* Make inline */}
|
|
{showInline && <Chip
|
|
color={REASONING_COLOR}
|
|
variant='soft'
|
|
size='sm'
|
|
disabled={!onFragmentReplace /* || props.messagePendingIncomplete */}
|
|
onClick={!onFragmentReplace ? undefined : handleInline}
|
|
endDecorator={<TextFieldsIcon />}
|
|
sx={_styles.chip}
|
|
// sx={(!onFragmentReplace /* || props.messagePendingIncomplete */) ? _styles.chipDisabled : _styles.chip}
|
|
>
|
|
Make Regular Text
|
|
</Chip>}
|
|
|
|
{/* Delete */}
|
|
{showDelete && <Chip
|
|
color={REASONING_COLOR}
|
|
variant='soft'
|
|
size='sm'
|
|
disabled={!onFragmentDelete /* || props.messagePendingIncomplete */}
|
|
onClick={!onFragmentDelete ? undefined : handleDelete}
|
|
endDecorator={<DeleteOutlineIcon />}
|
|
sx={_styles.chip}
|
|
// sx={(!onFragmentDelete /* || props.messagePendingIncomplete */) ? _styles.chipDisabled : _styles.chip}
|
|
>
|
|
Delete
|
|
</Chip>}
|
|
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
|
|
{/* Controlled Box */}
|
|
<ExpanderControlledBox expanded={expanded}>
|
|
|
|
{!neverExpanded && (
|
|
(ENABLE_MARKDOWN_DETECTION && maybeMarkdown) ? (
|
|
<Box sx={textSx}>
|
|
<RenderMarkdown content={props.auxText} sx={{ ...scaledTypographySx, marginInline: '0!important' /* to override what's default in this component */ }} />
|
|
{!!props.auxRedactedDataCount && <Box component='span' sx={{ color: 'text.disabled' }}> {ANTHROPIC_REDACTED_EXPLAINER}{'.'.repeat(props.auxRedactedDataCount % 5)}</Box>}
|
|
</Box>
|
|
) : (
|
|
<Typography sx={textSx}>
|
|
<span>
|
|
{props.auxText}
|
|
{!!props.auxRedactedDataCount && <Box component='span' sx={{ color: 'text.disabled' }}> {ANTHROPIC_REDACTED_EXPLAINER}{'.'.repeat(props.auxRedactedDataCount % 5)}</Box>}
|
|
</span>
|
|
</Typography>
|
|
)
|
|
)}
|
|
|
|
</ExpanderControlledBox>
|
|
|
|
</Box>;
|
|
} |