Composer: Extract some buttons, and support the useIsMobile() hook

This commit is contained in:
Enrico Ros
2023-11-13 17:50:19 -08:00
parent 140412cb8b
commit e1128fa38f
6 changed files with 181 additions and 149 deletions
@@ -0,0 +1,34 @@
import * as React from 'react';
import { Button, IconButton } from '@mui/joy';
import AddAPhotoIcon from '@mui/icons-material/AddAPhoto';
import { CameraCaptureModal } from './CameraCaptureModal';
const CAMERA_ENABLE_ON_DESKTOP = false; // process.env.NODE_ENV === 'development';
export function ButtonCameraCapture(props: { isMobile: boolean, onOCR: (ocrText: string) => void }) {
// state
const [open, setOpen] = React.useState(false);
return <>
{/* The Button */}
{props.isMobile ? (
<IconButton variant='plain' color='neutral' onClick={() => setOpen(true)}>
<AddAPhotoIcon />
</IconButton>
) : CAMERA_ENABLE_ON_DESKTOP ? (
<Button
fullWidth variant='plain' color='neutral' onClick={() => setOpen(true)} startDecorator={<AddAPhotoIcon />}
sx={{ justifyContent: 'flex-start' }}>
OCR
</Button>
) : undefined}
{/* The actual capture dialog, which will stream the video */}
{open && <CameraCaptureModal onCloseModal={() => setOpen(false)} onOCR={props.onOCR} />}
</>;
}
@@ -0,0 +1,31 @@
import * as React from 'react';
import { Box, Button, IconButton, Tooltip } from '@mui/joy';
import ContentPasteGoIcon from '@mui/icons-material/ContentPasteGo';
import { KeyStroke } from '~/common/components/KeyStroke';
const pasteClipboardLegend =
<Box sx={{ p: 1, lineHeight: 2 }}>
<b>Paste as 📚 Markdown attachment</b><br />
Also converts Code and Tables<br />
<KeyStroke light combo='Ctrl + Shift + V' />
</Box>;
export function ButtonClipboardPaste(props: { isMobile: boolean, isDeveloperMode: boolean, onPaste: () => void }) {
return props.isMobile ? (
<IconButton onClick={props.onPaste}>
<ContentPasteGoIcon />
</IconButton>
) : (
<Tooltip
variant='solid' placement='top-start'
title={pasteClipboardLegend}>
<Button fullWidth variant='plain' color='neutral' startDecorator={<ContentPasteGoIcon />} onClick={props.onPaste}
sx={{ justifyContent: 'flex-start' }}>
{props.isDeveloperMode ? 'Paste code' : 'Paste'}
</Button>
</Tooltip>
);
}
@@ -0,0 +1,70 @@
import { Box, Button, IconButton, Stack, Tooltip } from '@mui/joy';
import * as React from 'react';
import AttachFileOutlinedIcon from '@mui/icons-material/AttachFileOutlined';
const attachFileLegend =
<Stack sx={{ p: 1, gap: 1 }}>
<Box sx={{ mb: 1 }}>
<b>Attach a file</b>
</Box>
<table>
<tbody>
<tr>
<td><b>Text</b></td>
<td align='center' style={{ opacity: 0.5 }}></td>
<td>📝 As-is</td>
</tr>
<tr>
<td><b>Code</b></td>
<td align='center' style={{ opacity: 0.5 }}></td>
<td>📚 Markdown</td>
</tr>
<tr>
<td><b>PDF</b></td>
<td width={36} align='center' style={{ opacity: 0.5 }}></td>
<td>📝 Text (summarized)</td>
</tr>
</tbody>
</table>
<Box sx={{ mt: 1, fontSize: '14px' }}>
Drag & drop in chat for faster loads
</Box>
</Stack>;
export function ButtonFileAttach(props: { isMobile: boolean, onAttachFiles: (files: FileList) => Promise<void> }) {
// state
const attachmentFileInputRef = React.useRef<HTMLInputElement>(null);
const handleShowFilePicker = () => attachmentFileInputRef.current?.click();
const handleLoadAttachment = (event: React.ChangeEvent<HTMLInputElement>) => {
// NOTE: resetting the target value allows for the selector dialog to pop-up again
const files = event.target?.files;
if (files && files.length >= 1)
props.onAttachFiles(files).finally(() => event.target.value = '');
else
event.target.value = '';
};
return <>
{/* Mobile icon or Desktop button */}
{props.isMobile ? (
<IconButton onClick={handleShowFilePicker}>
<AttachFileOutlinedIcon />
</IconButton>
) : (
<Tooltip variant='solid' placement='top-start' title={attachFileLegend}>
<Button fullWidth variant='plain' color='neutral' onClick={handleShowFilePicker} startDecorator={<AttachFileOutlinedIcon />}
sx={{ justifyContent: 'flex-start' }}>
Attach
</Button>
</Tooltip>
)}
<input type='file' multiple hidden ref={attachmentFileInputRef} onChange={handleLoadAttachment} />
</>;
}
@@ -1,35 +0,0 @@
import * as React from 'react';
import { Button, IconButton } from '@mui/joy';
import AddAPhotoIcon from '@mui/icons-material/AddAPhoto';
import { hideOnDesktop, hideOnMobile } from '~/common/app.theme';
import { CameraCaptureModal } from './CameraCaptureModal';
const showOnDesktop = false; // process.env.NODE_ENV === 'development';
export function CameraCaptureButton(props: { onOCR: (ocrText: string) => void }) {
// state
const [open, setOpen] = React.useState(false);
return <>
{/* The Button */}
<IconButton variant='plain' color='neutral' onClick={() => setOpen(true)} sx={hideOnDesktop}>
<AddAPhotoIcon />
</IconButton>
{/* Also show a button on desktop while in development */}
{showOnDesktop && <Button
fullWidth variant='plain' color='neutral' onClick={() => setOpen(true)} startDecorator={<AddAPhotoIcon />}
sx={{ ...hideOnMobile, justifyContent: 'flex-start' }}>
OCR
</Button>}
{/* The actual capture dialog, which will stream the video */}
{open && <CameraCaptureModal onCloseModal={() => setOpen(false)} onOCR={props.onOCR} />}
</>;
}
+33 -105
View File
@@ -3,10 +3,8 @@ import { shallow } from 'zustand/shallow';
import { Box, Button, ButtonGroup, Card, Grid, IconButton, Stack, Textarea, Tooltip, Typography } from '@mui/joy';
import { ColorPaletteProp, SxProps, VariantProp } from '@mui/joy/styles/types';
import AttachFileOutlinedIcon from '@mui/icons-material/AttachFileOutlined';
import AutoModeIcon from '@mui/icons-material/AutoMode';
import CallIcon from '@mui/icons-material/Call';
import ContentPasteGoIcon from '@mui/icons-material/ContentPasteGo';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import FormatPaintIcon from '@mui/icons-material/FormatPaint';
import MicIcon from '@mui/icons-material/Mic';
@@ -26,21 +24,23 @@ import { KeyStroke } from '~/common/components/KeyStroke';
import { SpeechResult, useSpeechRecognition } from '~/common/components/useSpeechRecognition';
import { countModelTokens } from '~/common/util/token-counter';
import { extractFilePathsWithCommonRadix } from '~/common/util/dropTextUtils';
import { hideOnDesktop, hideOnMobile } from '~/common/app.theme';
import { htmlTableToMarkdown } from '~/common/util/htmlTableToMarkdown';
import { launchAppCall } from '~/common/app.routes';
import { openLayoutPreferences } from '~/common/layout/store-applayout';
import { pdfToText } from '~/common/util/pdfToText';
import { useChatStore } from '~/common/state/store-chats';
import { useDebouncer } from '~/common/components/useDebouncer';
import { useGlobalShortcut } from '~/common/components/useGlobalShortcut';
import { useIsMobile } from '~/common/components/useMatchMedia';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { CameraCaptureButton } from './CameraCaptureButton';
import { ButtonCameraCapture } from './ButtonCameraCapture';
import { ButtonClipboardPaste } from './ButtonClipboardPaste';
import { ButtonFileAttach } from './ButtonFileAttach';
import { ChatModeId, useComposerStartupText } from './store-composer';
import { ChatModeMenu } from './ChatModeMenu';
import { TokenBadge } from './TokenBadge';
import { TokenProgressbar } from './TokenProgressbar';
import { useDebouncer } from '~/common/components/useDebouncer';
/// Text template helpers
@@ -59,42 +59,6 @@ const expandPromptTemplate = (template: string, dict: object) => (inputValue: st
};
const attachFileLegend =
<Stack sx={{ p: 1, gap: 1 }}>
<Box sx={{ mb: 1 }}>
<b>Attach a file</b>
</Box>
<table>
<tbody>
<tr>
<td><b>Text</b></td>
<td align='center' style={{ opacity: 0.5 }}></td>
<td>📝 As-is</td>
</tr>
<tr>
<td><b>Code</b></td>
<td align='center' style={{ opacity: 0.5 }}></td>
<td>📚 Markdown</td>
</tr>
<tr>
<td><b>PDF</b></td>
<td width={36} align='center' style={{ opacity: 0.5 }}></td>
<td>📝 Text (summarized)</td>
</tr>
</tbody>
</table>
<Box sx={{ mt: 1, fontSize: '14px' }}>
Drag & drop in chat for faster loads
</Box>
</Stack>;
const pasteClipboardLegend =
<Box sx={{ p: 1, lineHeight: 2 }}>
<b>Paste as 📚 Markdown attachment</b><br />
Also converts Code and Tables<br />
<KeyStroke light combo='Ctrl + Shift + V' />
</Box>;
const MicButton = (props: { variant: VariantProp, color: ColorPaletteProp, onClick: () => void, sx?: SxProps }) =>
<Tooltip placement='top' title={
<Box sx={{ p: 1, lineHeight: 2, gap: 1 }}>
@@ -163,9 +127,9 @@ export function Composer(props: {
const [reducerText, setReducerText] = React.useState('');
const [reducerTextTokens, setReducerTextTokens] = React.useState(0);
const [chatModeMenuAnchor, setChatModeMenuAnchor] = React.useState<HTMLAnchorElement | null>(null);
const attachmentFileInputRef = React.useRef<HTMLInputElement>(null);
// external state
const isMobile = useIsMobile();
const [chatModeId, setChatModeId] = React.useState<ChatModeId>('immediate');
const [startupText, setStartupText] = useComposerStartupText();
const [enterIsNewline, experimentalLabs] = useUIPreferencesStore(state => [state.enterIsNewline, state.experimentalLabs], shallow);
@@ -188,7 +152,9 @@ export function Composer(props: {
}
}, [setComposeText, setStartupText, startupText]);
// derived state
const isDesktop = !isMobile;
const tokenLimit = chatLLM?.contextTokens || 0;
const directTokens = React.useMemo(() => {
return (!debouncedText || !chatLLMId) ? 4 : 4 + countModelTokens(debouncedText, chatLLMId, 'composer text');
@@ -286,7 +252,7 @@ export function Composer(props: {
const micVariant: VariantProp = isRecordingSpeech ? 'solid' : isRecordingAudio ? 'outlined' : 'plain';
async function loadAndAttachFiles(files: FileList, overrideFileNames: string[]) {
async function loadAndAttachFiles(files: FileList, overrideFileNames?: string[]): Promise<void> {
// NOTE: we tried to get the common 'root prefix' of the files here, so that we could attach files with a name that's relative
// to the common root, but the files[].webkitRelativePath property is not providing that information
@@ -295,7 +261,7 @@ export function Composer(props: {
let newText = '';
for (let i = 0; i < files.length; i++) {
const file = files[i];
const fileName = overrideFileNames.length === files.length ? overrideFileNames[i] : file.name;
const fileName = overrideFileNames?.length === files.length ? overrideFileNames[i] : file.name;
let fileText = '';
try {
if (file.type === 'application/pdf')
@@ -335,20 +301,9 @@ export function Composer(props: {
setComposeText(text => text + newText);
};
const handleShowFilePicker = () => attachmentFileInputRef.current?.click();
const handleLoadAttachment = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target?.files;
if (files && files.length >= 1)
await loadAndAttachFiles(files, []);
// this is needed to allow the same file to be selected again
e.target.value = '';
};
const handleCameraOCR = (text: string) => text && setComposeText(expandPromptTemplate(PromptTemplates.PasteMarkdown, { clipboard: text }));
const handlePasteButtonClicked = React.useCallback(async () => {
const handlePasteClipboard = React.useCallback(async () => {
for (const clipboardItem of await navigator.clipboard.read()) {
// when pasting html, only process tables as markdown (e.g. from Excel), or fallback to text
@@ -381,14 +336,14 @@ export function Composer(props: {
}
}, [setComposeText]);
useGlobalShortcut('v', true, true, false, handlePasteButtonClicked);
useGlobalShortcut('v', true, true, false, handlePasteClipboard);
const handleTextareaCtrlV = async (e: React.ClipboardEvent) => {
const handleTextareaCtrlV = async (event: React.ClipboardEvent) => {
// paste local files
if (e.clipboardData.files.length > 0) {
e.preventDefault();
await loadAndAttachFiles(e.clipboardData.files, []);
if (event.clipboardData.files?.length) {
event.preventDefault();
await loadAndAttachFiles(event.clipboardData.files, []);
return;
}
@@ -445,6 +400,7 @@ export function Composer(props: {
console.log('Unhandled Drop event. Contents: ', e.dataTransfer.types.map(t => `${t}: ${e.dataTransfer.getData(t)}`));
};
const isImmediate = chatModeId === 'immediate';
const isWriteUser = chatModeId === 'write-user';
const isChat = isImmediate || isWriteUser;
@@ -464,6 +420,7 @@ export function Composer(props: {
? 'Chat with me · drop source files · attach code...'
: /*isProdiaConfigured ?*/ 'Chat · /react · /imagine · drop text files...' /*: 'Chat · /react · drop text files...'*/;
return (
<Box sx={props.sx}>
<Grid container spacing={{ xs: 1, md: 2 }}>
@@ -475,40 +432,16 @@ export function Composer(props: {
<Box sx={{ display: 'flex', flexDirection: 'column', gap: { xs: 0, md: 2 } }}>
{/* [mobile] Mic button */}
{isSpeechEnabled && <Box sx={hideOnDesktop}>
<MicButton variant={micVariant} color={micColor} onClick={handleMicClicked} />
</Box>}
{isMobile && isSpeechEnabled && <MicButton variant={micVariant} color={micColor} onClick={handleMicClicked} />}
{/* Responsive Camera OCR button */}
<CameraCaptureButton onOCR={handleCameraOCR} />
<ButtonCameraCapture isMobile={isMobile} onOCR={handleCameraOCR} />
{/* Responsive Attach button */}
<IconButton onClick={handleShowFilePicker} sx={{ ...hideOnDesktop }}>
<AttachFileOutlinedIcon />
</IconButton>
<Tooltip
variant='solid' placement='top-start'
title={attachFileLegend}>
<Button fullWidth variant='plain' color='neutral' onClick={handleShowFilePicker} startDecorator={<AttachFileOutlinedIcon />}
sx={{ ...hideOnMobile, justifyContent: 'flex-start' }}>
Attach
</Button>
</Tooltip>
<ButtonFileAttach isMobile={isMobile} onAttachFiles={loadAndAttachFiles} />
{/* Responsive Paste button */}
<IconButton onClick={handlePasteButtonClicked} sx={{ ...hideOnDesktop }}>
<ContentPasteGoIcon />
</IconButton>
<Tooltip
variant='solid' placement='top-start'
title={pasteClipboardLegend}>
<Button fullWidth variant='plain' color='neutral' startDecorator={<ContentPasteGoIcon />} onClick={handlePasteButtonClicked}
sx={{ ...hideOnMobile, justifyContent: 'flex-start' }}>
{props.isDeveloperMode ? 'Paste code' : 'Paste'}
</Button>
</Tooltip>
<input type='file' multiple hidden ref={attachmentFileInputRef} onChange={handleLoadAttachment} />
<ButtonClipboardPaste isMobile={isMobile} isDeveloperMode={props.isDeveloperMode} onPaste={handlePasteClipboard} />
</Box>
@@ -556,7 +489,7 @@ export function Composer(props: {
m: 1,
display: 'flex', flexDirection: 'column', gap: 1,
}}>
<MicButton variant={micVariant} color={micColor} onClick={handleMicClicked} sx={hideOnMobile} />
{isDesktop && <MicButton variant={micVariant} color={micColor} onClick={handleMicClicked} />}
{micIsRunning && (
<MicContinuationButton
@@ -624,19 +557,11 @@ export function Composer(props: {
{/* first row of buttons */}
<Box sx={{ display: 'flex' }}>
{/* [mobile, corner] Call secondary button */}
{isMobile && isChat && <CallButtonMobile disabled={!APP_CALL_ENABLED || !props.conversationId || !chatLLM} onClick={handleCallClicked} sx={{ mr: { xs: 1, md: 2 } }} />}
{/* [mobile] [corner] Call button */}
{isChat && <CallButtonMobile
disabled={!APP_CALL_ENABLED || !props.conversationId || !chatLLM}
onClick={handleCallClicked}
sx={{ ...hideOnDesktop, mr: { xs: 1, md: 2 } }}
/>}
{/* [mobile] [corner] Draw button */}
{(isDraw || isDrawPlus) && <DrawOptionsButtonMobile
onClick={handleDrawOptionsClicked}
sx={{ ...hideOnDesktop, mr: { xs: 1, md: 2 } }}
/>}
{/* [mobile, corner] Draw Options secondary button */}
{isMobile && (isDraw || isDrawPlus) && <DrawOptionsButtonMobile onClick={handleDrawOptionsClicked} sx={{ mr: { xs: 1, md: 2 } }} />}
{/* Responsive Send/Stop buttons */}
{assistantTyping
@@ -666,13 +591,16 @@ export function Composer(props: {
</Box>
{/* [desktop] other buttons (aligned to bottom for now, and mutually exclusive) */}
<Box sx={{ flexGrow: 1, flexDirection: 'column', gap: 1, justifyContent: 'flex-end', ...hideOnMobile }}>
{/* [desktop] secondary buttons (aligned to bottom for now, and mutually exclusive) */}
{isDesktop && <Box sx={{ flexGrow: 1, flexDirection: 'column', gap: 1, justifyContent: 'flex-end' }}>
{/* [desktop] Call secondary button */}
{isChat && <CallButtonDesktop disabled={!APP_CALL_ENABLED || !props.conversationId || !chatLLM} onClick={handleCallClicked} />}
{/* [desktop] Draw Options secondary button */}
{(isDraw || isDrawPlus) && <DrawOptionsButtonDesktop onClick={handleDrawOptionsClicked} />}
</Box>
</Box>}
</Box>
</Grid>
+13 -9
View File
@@ -11,7 +11,7 @@ import { createModelSourceForVendor, findAllVendors, findVendorById } from '~/mo
import { CloseableMenu } from '~/common/components/CloseableMenu';
import { ConfirmationModal } from '~/common/components/ConfirmationModal';
import { hideOnDesktop, hideOnMobile } from '~/common/app.theme';
import { useIsMobile } from '~/common/components/useMatchMedia';
/*function locationIcon(vendor?: IModelVendor | null) {
@@ -43,6 +43,7 @@ export function ModelsSourceSelector(props: {
const [confirmDeletionSourceId, setConfirmDeletionSourceId] = React.useState<DModelSourceId | null>(null);
// external state
const isMobile = useIsMobile();
const { modelSources, addModelSource, removeModelSource } = useModelsStore(state => ({
modelSources: state.sources,
addModelSource: state.addSource, removeModelSource: state.removeSource,
@@ -115,9 +116,9 @@ export function ModelsSourceSelector(props: {
<Box sx={{ display: 'flex', flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 1 }}>
{/* Models: [Select] Add Delete */}
<Typography sx={{ mr: 1, ...hideOnMobile }}>
{!isMobile && <Typography sx={{ mr: 1 }}>
Service:
</Typography>
</Typography>}
<Select
variant='outlined'
@@ -133,12 +134,15 @@ export function ModelsSourceSelector(props: {
{sourceItems.map(item => item.component)}
</Select>
<IconButton variant={noSources ? 'solid' : 'plain'} color='primary' onClick={handleShowVendors} disabled={!!vendorsMenuAnchor} sx={{ ...hideOnDesktop }}>
<AddIcon />
</IconButton>
<Button variant={noSources ? 'solid' : 'plain'} onClick={handleShowVendors} disabled={!!vendorsMenuAnchor} startDecorator={<AddIcon />} sx={{ ...hideOnMobile }}>
Add
</Button>
{isMobile ? (
<IconButton variant={noSources ? 'solid' : 'plain'} color='primary' onClick={handleShowVendors} disabled={!!vendorsMenuAnchor}>
<AddIcon />
</IconButton>
) : (
<Button variant={noSources ? 'solid' : 'plain'} onClick={handleShowVendors} disabled={!!vendorsMenuAnchor} startDecorator={<AddIcon />}>
Add
</Button>
)}
<IconButton
variant='plain' color='neutral' disabled={!enableDeleteButton} sx={{ ml: 'auto' }}