AutoPrompts: cleanup

This commit is contained in:
Enrico Ros
2024-08-03 23:35:46 -07:00
parent f0240018d6
commit 1fc61f7c78
8 changed files with 144 additions and 92 deletions
+16 -9
View File
@@ -14,18 +14,20 @@ import SendIcon from '@mui/icons-material/Send';
import StopOutlinedIcon from '@mui/icons-material/StopOutlined';
import TelegramIcon from '@mui/icons-material/Telegram';
import { useChatMicTimeoutMsValue } from '../../store-app-chat';
import { useChatAutoSuggestAttachmentPrompts, useChatMicTimeoutMsValue } from '../../store-app-chat';
import type { DLLM } from '~/modules/llms/store-llms';
import type { LLMOptionsOpenAI } from '~/modules/llms/vendors/openai/openai.vendor';
import { useAgiAttachmentPrompts } from '~/modules/aifn/attachmentprompts/useAgiAttachmentPrompts';
import { useBrowseCapability } from '~/modules/browse/store-module-browsing';
import { AudioGenerator } from '~/common/util/audio/AudioGenerator';
import { AudioPlayer } from '~/common/util/audio/AudioPlayer';
import { ButtonAttachFilesMemo } from '~/common/components/ButtonAttachFiles';
import { ChatBeamIcon } from '~/common/components/icons/ChatBeamIcon';
import { ConversationsManager } from '~/common/chat-overlay/ConversationsManager';
import { ConversationsManager } from '~/common/chat-overlay/ConversationsManager';
import { DMessageMetadata, messageFragmentsReduceText } from '~/common/stores/chat/chat.message';
import { ShortcutKey, ShortcutObject, useGlobalShortcuts } from '~/common/components/shortcuts/useGlobalShortcuts';
import { animationEnterBelow } from '~/common/util/animUtils';
import { browserSpeechRecognitionCapability, SpeechResult, useSpeechRecognition } from '~/common/components/useSpeechRecognition';
import { conversationTitle, DConversationId } from '~/common/stores/chat/chat.conversation';
@@ -42,7 +44,6 @@ import { supportsScreenCapture } from '~/common/util/screenCaptureUtils';
import { useAppStateStore } from '~/common/state/store-appstate';
import { useChatOverlayStore } from '~/common/chat-overlay/store-chat-overlay';
import { useDebouncer } from '~/common/components/useDebouncer';
import { ShortcutKey, ShortcutObject, useGlobalShortcuts } from '~/common/components/shortcuts/useGlobalShortcuts';
import { useUICounter, useUIPreferencesStore } from '~/common/state/store-ui';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
@@ -142,7 +143,7 @@ export function Composer(props: {
// composer-overlay: for the reply-to state, comes from the conversation overlay
const { replyToGenerateText } = useChatOverlayStore(conversationOverlayStore, useShallow(store => ({
replyToGenerateText: (chatExecuteMode === 'generate-content' || chatExecuteMode === 'generate-text-v1') ? store.replyToText?.trim() || null : null,
replyToGenerateText: (chatExecuteMode === 'generate-content' || chatExecuteMode === 'generate-text-v1') ? store.replyToText?.trim() ?? undefined : undefined,
})));
// don't load URLs if the user is typing a command or there's no capability
@@ -161,6 +162,11 @@ export function Composer(props: {
// drag/drop
const { dragContainerSx, dragDropComponent, handleDragEnter, handleDragStart } = useDragDrop(!!props.isMobile, attachAppendDataTransfer);
// ai functions
const {
agiAttachmentPrompts, agiAttachmentPromptsComponent, agiAttachmentPromptsRefetch,
} = useAgiAttachmentPrompts(useChatAutoSuggestAttachmentPrompts(), attachmentDrafts);
// derived state
@@ -253,7 +259,7 @@ export function Composer(props: {
}, [attachmentsTakeAllFragments, handleClear, onAction, replyToGenerateText, targetConversationId]);
const handleAppendAndSend = React.useCallback(async (appendText: string) => {
const handleAppendTextAndSend = React.useCallback(async (appendText: string) => {
const newText = composeText ? `${composeText} ${appendText}` : appendText;
setComposeText(newText);
await handleSendAction(chatExecuteMode, newText);
@@ -664,11 +670,11 @@ export function Composer(props: {
// onBlurCapture={handleFocusModeOff}
endDecorator={
<ComposerTextAreaActions
attachmentDrafts={attachmentDrafts}
showChatReplyTo={showChatReplyTo}
replyToGenerateText={replyToGenerateText}
onAppendAndSend={handleAppendAndSend}
agiAttachmentButton={agiAttachmentPromptsComponent}
agiAttachmentPrompts={agiAttachmentPrompts}
onAppendAndSend={handleAppendTextAndSend}
onReplyToClear={handleReplyToClear}
replyToText={replyToGenerateText}
/>
}
slotProps={{
@@ -759,6 +765,7 @@ export function Composer(props: {
llmAttachmentDrafts={llmAttachmentDraftsCollection.llmAttachmentDrafts}
canInlineSomeFragments={llmAttachmentDraftsCollection.canInlineSomeFragments}
onAttachmentDraftsAction={handleAttachmentDraftsAction}
onAgiAttachmentPromptsRefetch={agiAttachmentPromptsRefetch}
/>
)}
@@ -1,44 +1,20 @@
import * as React from 'react';
import { useQuery } from '@tanstack/react-query';
import { Alert, Box, Button, CircularProgress, Sheet } from '@mui/joy';
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import { proposeActionsForAttachments } from '~/modules/aifn/autoprompts/autoprompts';
import type { AttachmentDraft } from '~/common/attachment-drafts/attachment.types';
import { useShallowStable } from '~/common/util/hooks/useShallowObject';
import { Box, Sheet } from '@mui/joy';
import { ReplyToBubble } from '../message/ReplyToBubble';
import { getChatAutoAI } from '../../store-app-chat';
export function ComposerTextAreaActions(props: {
attachmentDrafts: AttachmentDraft[],
showChatReplyTo: boolean,
replyToGenerateText: string | null,
agiAttachmentButton?: React.ReactNode,
agiAttachmentPrompts?: string[],
replyToText?: string,
onAppendAndSend: (appendText: string) => Promise<void>,
onReplyToClear: () => void,
}) {
// external state
const { autoSuggestAttachmentPrompts } = getChatAutoAI();
const allFragments = useShallowStable(props.attachmentDrafts.flatMap(draft => draft.outputFragments));
const enableAttachmentGuess = autoSuggestAttachmentPrompts && allFragments.length >= 2;
const { data: attachmentInstructionCandidates, error, isPending, isFetching, refetch } = useQuery({
enabled: enableAttachmentGuess,
queryKey: ['attachment-guess', ...allFragments.map(f => f.fId).sort()],
queryFn: async (context) => proposeActionsForAttachments(allFragments, context.signal),
staleTime: 1000 * 60 * 5, // 5 minutes
});
const handleUpdateAttachmentGuess = React.useCallback(async () => await refetch(), [refetch]);
if (!props.showChatReplyTo && !enableAttachmentGuess)
// skip the component if there's nothing to show
if (!props.agiAttachmentPrompts?.length && !props.agiAttachmentButton && props.replyToText === undefined)
return null;
return (
@@ -63,17 +39,17 @@ export function ComposerTextAreaActions(props: {
}}>
{/* Reply-To bubble */}
{props.showChatReplyTo && (
{props.replyToText !== undefined && (
<ReplyToBubble
replyToText={props.replyToGenerateText}
replyToText={props.replyToText}
onClear={props.onReplyToClear}
className='reply-to-bubble'
/>
)}
{/* User Prompt candidates */}
{enableAttachmentGuess && !!attachmentInstructionCandidates?.length && (
attachmentInstructionCandidates.map((candidate, index) => (
{/* Auto-Prompts from attachments */}
{!!props.agiAttachmentPrompts?.length && (
props.agiAttachmentPrompts.map((candidate, index) => (
<Sheet
key={index}
color='primary'
@@ -103,31 +79,7 @@ export function ComposerTextAreaActions(props: {
)}
{/* Guess Action Button */}
{enableAttachmentGuess && <Box sx={{ display: 'flex', gap: 1, mb: 0.5 }}>
{/* Guess / Guess Again */}
<Button
variant='outlined'
color='primary'
disabled={isFetching}
endDecorator={isFetching ? <CircularProgress color='neutral' sx={{ '--CircularProgress-size': '16px' }} /> : <AutoFixHighIcon sx={{ fontSize: '20px' }} />}
onClick={handleUpdateAttachmentGuess}
sx={{
px: 3,
backgroundColor: 'background.surface',
boxShadow: '0 4px 6px -4px rgb(var(--joy-palette-primary-darkChannel) / 40%)',
borderRadius: 'sm',
}}
>
{isFetching ? 'Guessing what to do...' : isPending ? 'Guess what to do' : 'What else could we do'}
</Button>
{!!error && <Alert variant='soft' color='danger'>
{error.message || 'Error guessing actions'}
</Alert>}
</Box>}
{props.agiAttachmentButton}
</Box>
);
@@ -1,6 +1,7 @@
import * as React from 'react';
import { Box, IconButton, ListDivider, ListItemDecorator, MenuItem } from '@mui/joy';
import { Box, CircularProgress, IconButton, ListDivider, ListItemDecorator, MenuItem } from '@mui/joy';
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import ClearIcon from '@mui/icons-material/Clear';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
@@ -8,6 +9,7 @@ import VerticalAlignBottomIcon from '@mui/icons-material/VerticalAlignBottom';
import { CloseableMenu } from '~/common/components/CloseableMenu';
import { ConfirmationModal } from '~/common/components/ConfirmationModal';
import { useAsyncCall } from '~/common/util/hooks/useAsyncCall';
import type { AttachmentDraftId } from '~/common/attachment-drafts/attachment.types';
import type { AttachmentDraftsStoreApi } from '~/common/attachment-drafts/store-attachment-drafts-slice';
@@ -28,12 +30,14 @@ export function LLMAttachmentsList(props: {
llmAttachmentDrafts: LLMAttachmentDraft[];
canInlineSomeFragments: boolean;
onAttachmentDraftsAction: (attachmentDraftId: AttachmentDraftId | null, actionId: LLMAttachmentDraftsAction) => void,
onAgiAttachmentPromptsRefetch: () => Promise<any>,
}) {
// state
const [confirmClearAttachmentDrafts, setConfirmClearAttachmentDrafts] = React.useState<boolean>(false);
const [draftMenu, setDraftMenu] = React.useState<{ anchor: HTMLAnchorElement, attachmentDraftId: AttachmentDraftId } | null>(null);
const [overallMenuAnchor, setOverallMenuAnchor] = React.useState<HTMLAnchorElement | null>(null);
const [overallIsAgiRefetchingPrompts, handleOverallAgiPromptsRefetch] = useAsyncCall(props.onAgiAttachmentPromptsRefetch);
// derived state
@@ -155,6 +159,14 @@ export function LLMAttachmentsList(props: {
anchorEl={overallMenuAnchor} onClose={handleOverallMenuHide}
sx={{ minWidth: 200 }}
>
{/* uses the agiAttachmentPrompts to imagine what the user will ask aboud those */}
<MenuItem color='primary' variant='soft' onClick={handleOverallAgiPromptsRefetch} disabled={!hasAttachments || overallIsAgiRefetchingPrompts}>
<ListItemDecorator>{overallIsAgiRefetchingPrompts ? <CircularProgress size='sm' /> : <AutoFixHighIcon />}</ListItemDecorator>
What to do?
</MenuItem>
<ListDivider />
<MenuItem onClick={handleOverallInlineText} disabled={!canInlineSomeFragments}>
<ListItemDecorator><VerticalAlignBottomIcon /></ListItemDecorator>
Inline all text
@@ -163,7 +175,9 @@ export function LLMAttachmentsList(props: {
<ListItemDecorator><ContentCopyIcon /></ListItemDecorator>
Copy all text
</MenuItem>
<ListDivider />
<MenuItem onClick={handleOverallClear}>
<ListItemDecorator><ClearIcon /></ListItemDecorator>
Remove All{llmAttachmentDrafts.length > 5 ? <span style={{ opacity: 0.5 }}> {llmAttachmentDrafts.length} attachments</span> : null}
@@ -53,7 +53,7 @@ export const inlineMessageBubbleSx: SxProps = {
export function ReplyToBubble(props: {
replyToText: string | null,
replyToText?: string,
inlineUserMessage?: boolean
onClear?: () => void,
className?: string,
+3
View File
@@ -156,6 +156,9 @@ export const getChatAutoAI = (): {
export const useChatAutoSuggestHTMLUI = (): boolean =>
useAppChatStore(state => state.autoSuggestHTMLUI);
export const useChatAutoSuggestAttachmentPrompts = (): boolean =>
useAppChatStore(state => state.autoSuggestAttachmentPrompts);
export const useChatMicTimeoutMsValue = (): number =>
useAppChatStore(state => state.micTimeoutMs);
+20 -20
View File
@@ -3,7 +3,7 @@ import * as React from 'react';
interface AsyncState<TData> {
isLoading: boolean;
error: Error | null;
data: TData | null;
lastSuccessfulData: TData | null;
}
type AsyncFunction<TArgs extends any[], TResult> = (...args: TArgs) => Promise<TResult>;
@@ -22,9 +22,9 @@ type AsyncCallResult<TArgs extends any[], TResult> = [
* Ideal for standardizing async patterns in React applications, particularly
* for API calls, data fetching, and other asynchronous tasks:
*
* - react to state: loading, error, and data being set
* - react to state: LOADING (the main reason for this), error, and data being set
* - type safe: maintain typescript types throughout
* - misc: prevent stale closures, memory leaks (on unmount), boilerplate
* - misc: prevent stale closures, memory leaks (on unmount - NOTE: REMOVED!), boilerplate
*/
export function useAsyncCall<TArgs extends any[], TResult>(asyncFunction: AsyncFunction<TArgs, TResult>): AsyncCallResult<TArgs, TResult> {
@@ -32,40 +32,40 @@ export function useAsyncCall<TArgs extends any[], TResult>(asyncFunction: AsyncF
const [state, setState] = React.useState<AsyncState<TResult>>({
isLoading: false,
error: null,
data: null,
lastSuccessfulData: null,
});
const isMounted = React.useRef(true);
const latestAsyncFunction = React.useRef(asyncFunction);
React.useEffect(() => {
latestAsyncFunction.current = asyncFunction;
return () => {
isMounted.current = false;
};
}, [asyncFunction]);
const execute = React.useCallback(async (...args: TArgs): Promise<TResult> => {
setState(prev => ({ ...prev, isLoading: true, error: null }));
setState(prev => ({
...prev,
isLoading: true,
error: null,
}));
try {
const result = await latestAsyncFunction.current(...args);
if (isMounted.current) {
setState({ isLoading: false, error: null, data: result });
}
setState({
isLoading: false,
error: null,
lastSuccessfulData: result,
});
return result;
} catch (err) {
const error = err instanceof Error ? err : new Error('An error occurred');
if (isMounted.current) {
setState(prev => ({
...prev,
isLoading: false,
error,
}));
}
setState(prev => ({
...prev,
isLoading: false,
error,
}));
throw error;
}
}, []);
return [state.isLoading, execute, state.error, state.data];
return [state.isLoading, execute, state.error, state.lastSuccessfulData];
}
@@ -18,7 +18,7 @@ function aixSystemMessage(text: string) {
}
export async function proposeActionsForAttachments(allFragments: DMessageAttachmentFragment[], abortSignal: AbortSignal) {
export async function agiAttachmentPrompts(allFragments: DMessageAttachmentFragment[], abortSignal: AbortSignal) {
// sanity checks
const llmId = getChatLLMId();
const docParts = allFragments.filter(f => f.part.pt === 'doc').map(f => f.part) as DMessageDocPart[];
@@ -0,0 +1,76 @@
import * as React from 'react';
import { useQuery } from '@tanstack/react-query';
import { Alert, Box, Button, CircularProgress } from '@mui/joy';
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import type { AttachmentDraft } from '~/common/attachment-drafts/attachment.types';
import { useShallowStable } from '~/common/util/hooks/useShallowObject';
import { agiAttachmentPrompts } from './agiAttachmentPrompts';
export function useAgiAttachmentPrompts(automatic: boolean, attachmentDrafts: AttachmentDraft[]) {
// external state
const stableFragments = useShallowStable(attachmentDrafts.flatMap(draft => draft.outputFragments));
// derived state
const automaticTrigger = automatic && stableFragments.length >= 2;
// async operation state
const { data: prompts, error, isPending, isFetching, isLoading, isRefetching, isStale, refetch } = useQuery({
enabled: automaticTrigger,
queryKey: ['aifn-prompts-attachments', ...stableFragments.map(f => f.fId).sort()],
queryFn: async ({ signal }) => agiAttachmentPrompts(stableFragments, signal),
staleTime: Infinity,
// placeholderData: keepPreviousData,
});
console.log('state', { isPending, isFetching, isLoading, isRefetching, isStale });
// callbacks
const handleRefetch = React.useCallback(async () => refetch(), [refetch]);
// memoed components
const hasData = !!prompts && prompts.length > 0;
const showComponent = hasData || automaticTrigger;
const component = React.useMemo(() => {
return !showComponent ? null : (
<Box sx={{ display: 'flex', gap: 1, mb: 0.5 }}>
<Button
variant='outlined'
color='primary'
disabled={isFetching}
endDecorator={
isFetching ? <CircularProgress color='neutral' sx={{ '--CircularProgress-size': '16px' }} />
: <AutoFixHighIcon sx={{ fontSize: '20px' }} />
}
onClick={handleRefetch}
sx={{
px: 3,
backgroundColor: 'background.surface',
boxShadow: '0 4px 6px -4px rgb(var(--joy-palette-primary-darkChannel) / 40%)',
borderRadius: 'sm',
}}
>
{isFetching ? 'Guessing what to do...' : isPending ? 'Guess what to do' : 'What else could we do'}
</Button>
{!!error && (
<Alert variant='soft' color='danger'>
{error.message || 'Error guessing actions'}
</Alert>
)}
</Box>
);
}, [error, handleRefetch, isFetching, isPending, showComponent]);
return {
agiAttachmentPrompts: prompts,
agiAttachmentPromptsRefetch: refetch,
agiAttachmentPromptsComponent: component,
};
}