diff --git a/src/apps/chat/AppChat.tsx b/src/apps/chat/AppChat.tsx index ee206c704..adca71759 100644 --- a/src/apps/chat/AppChat.tsx +++ b/src/apps/chat/AppChat.tsx @@ -44,6 +44,8 @@ import { ChatPageMenuItems } from './components/layout-menu/ChatPageMenuItems'; import { Composer } from './components/composer/Composer'; import { usePanesManager } from './components/panes/usePanesManager'; +import type { ChatExecuteMode } from './execute-mode/execute-mode.types'; + import { _handleExecute } from './editors/_handleExecute'; import { gcChatImageAssets } from './editors/image-generate'; @@ -52,19 +54,6 @@ import { gcChatImageAssets } from './editors/image-generate'; export const CHAT_NOVEL_TITLE = 'Chat'; -/** - * Mode: how to treat the input from the Composer - */ -export type ChatModeId = - | 'append-user' - | 'beam-content' - | 'generate-content' - | 'generate-image' - | 'generate-text-v1' - | 'react-content' - ; - - export interface AppChatIntent { initialConversationId: string | null; } @@ -206,8 +195,8 @@ export function AppChat() { // Execution - const handleExecuteAndOutcome = React.useCallback(async (chatModeId: ChatModeId, conversationId: DConversationId, callerNameDebug: string) => { - const outcome = await _handleExecute(chatModeId, conversationId, callerNameDebug); + const handleExecuteAndOutcome = React.useCallback(async (chatExecuteMode: ChatExecuteMode, conversationId: DConversationId, callerNameDebug: string) => { + const outcome = await _handleExecute(chatExecuteMode, conversationId, callerNameDebug); if (outcome === 'err-no-chatllm') openModelsSetup(); else if (outcome === 'err-t2i-unconfigured') @@ -221,7 +210,7 @@ export function AppChat() { return outcome === true; }, [openModelsSetup, openPreferencesTab]); - const handleComposerAction = React.useCallback((conversationId: DConversationId, chatModeId: ChatModeId, fragments: (DMessageContentFragment | DMessageAttachmentFragment)[], metadata?: DMessageMetadata): boolean => { + const handleComposerAction = React.useCallback((conversationId: DConversationId, chatExecuteMode: ChatExecuteMode, fragments: (DMessageContentFragment | DMessageAttachmentFragment)[], metadata?: DMessageMetadata): boolean => { // [multicast] send the message to all the panes const uniqueConversationIds = willMulticast @@ -245,7 +234,7 @@ export function AppChat() { ConversationsManager.getHandler(conversation.id).messageAppend(userMessage); // [chat] append user message in each conversation // fire/forget - void handleExecuteAndOutcome(chatModeId /* various */, conversation.id, 'chat-composer-action'); // append user message, then '*-*' + void handleExecuteAndOutcome(chatExecuteMode /* various */, conversation.id, 'chat-composer-action'); // append user message, then '*-*' } return true; diff --git a/src/apps/chat/components/composer/ChatModeMenu.tsx b/src/apps/chat/components/composer/ChatModeMenu.tsx deleted file mode 100644 index 9db328a10..000000000 --- a/src/apps/chat/components/composer/ChatModeMenu.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import * as React from 'react'; - -import { Box, MenuItem, Radio, Typography } from '@mui/joy'; - -import { CloseableMenu } from '~/common/components/CloseableMenu'; -import { KeyStroke, platformAwareKeystrokes } from '~/common/components/KeyStroke'; -import { useUIPreferencesStore } from '~/common/state/store-ui'; - -import type { ChatModeId } from '../../AppChat'; - - -export function chatModeCanAttach(chatModeId: ChatModeId) { - return !!ChatModeItems[chatModeId]?.canAttach; -} - -const ChatModeItems: { [key in ChatModeId]: ChatModeDescription } = { - 'generate-content': { - label: 'Chat', - description: 'Persona replies', - canAttach: true, - }, - 'generate-text-v1': { - label: 'Chat (Stable)', - description: 'Model replies (stable)', - canAttach: true, - }, - 'beam-content': { - label: 'Beam', // Best of, Auto-Prime, Top Pick, Select Best - description: 'Combine multiple models', // Smarter: combine... - shortcut: 'Ctrl + Enter', - canAttach: true, - hideOnDesktop: true, - }, - 'append-user': { - label: 'Write', - description: 'Append a message', - shortcut: 'Alt + Enter', - canAttach: true, - }, - 'generate-image': { - label: 'Draw', - description: 'AI Image Generation', - requiresTTI: true, - }, - 'react-content': { - label: 'Reason + Act', // · α - description: 'Answer questions in multiple steps', - }, -}; - -interface ChatModeDescription { - label: string; - description: string | React.JSX.Element; - canAttach?: boolean; - highlight?: boolean; - shortcut?: string; - hideOnDesktop?: boolean; - requiresTTI?: boolean; -} - - -function fixNewLineShortcut(shortcut: string, enterIsNewLine: boolean) { - if (shortcut === 'ENTER') - return enterIsNewLine ? 'Shift + Enter' : 'Enter'; - return shortcut; -} - -export function ChatModeMenu(props: { - isMobile: boolean, - anchorEl: HTMLAnchorElement | null, - onClose: () => void, - chatModeId: ChatModeId, - onSetChatModeId: (chatMode: ChatModeId) => void, - capabilityHasTTI: boolean, -}) { - - // external state - const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline); - - return ( - - - {/**/} - {/* Conversation Mode*/} - {/**/} - {/**/} - {/**/} - - {/* ChatMode items */} - {Object.entries(ChatModeItems) - .filter(([_key, data]) => !data.hideOnDesktop || props.isMobile) - .map(([key, data]) => - props.onSetChatModeId(key as ChatModeId)}> - - - - {data.label} - {data.description}{(data.requiresTTI && !props.capabilityHasTTI) ? 'Unconfigured' : ''} - - {(key === props.chatModeId || !!data.shortcut) && ( - - )} - - )} - - - ); -} \ No newline at end of file diff --git a/src/apps/chat/components/composer/Composer.tsx b/src/apps/chat/components/composer/Composer.tsx index 738ede0ea..2786ee39b 100644 --- a/src/apps/chat/components/composer/Composer.tsx +++ b/src/apps/chat/components/composer/Composer.tsx @@ -15,7 +15,6 @@ import SendIcon from '@mui/icons-material/Send'; import StopOutlinedIcon from '@mui/icons-material/StopOutlined'; import TelegramIcon from '@mui/icons-material/Telegram'; -import type { ChatModeId } from '../../AppChat'; import { useChatMicTimeoutMsValue } from '../../store-app-chat'; import type { DLLM } from '~/modules/llms/store-llms'; @@ -56,6 +55,9 @@ import { LLMAttachmentDraftsAction, LLMAttachmentsList } from './llmattachments/ import { useAttachmentDrafts } from '~/common/attachment-drafts/useAttachmentDrafts'; import { useLLMAttachmentDrafts } from './llmattachments/useLLMAttachmentDrafts'; +import type { ChatExecuteMode } from '../../execute-mode/execute-mode.types'; +import { chatExecuteModeCanAttach, useChatExecuteMode } from '../../execute-mode/useChatExecuteMode'; + import { ButtonAttachCameraMemo, useCameraCaptureModal } from './buttons/ButtonAttachCamera'; import { ButtonAttachClipboardMemo } from './buttons/ButtonAttachClipboard'; import { ButtonAttachFileMemo } from './buttons/ButtonAttachFile'; @@ -69,7 +71,6 @@ import { ButtonOptionsDraw } from './buttons/ButtonOptionsDraw'; import { ReplyToBubble } from '../message/ReplyToBubble'; import { TokenBadgeMemo } from './TokenBadge'; import { TokenProgressbarMemo } from './TokenProgressbar'; -import { chatModeCanAttach, ChatModeMenu } from './ChatModeMenu'; import { useComposerStartupText } from './store-composer'; @@ -103,19 +104,19 @@ export function Composer(props: { capabilityHasT2I: boolean; isMulticast: boolean | null; isDeveloperMode: boolean; - onAction: (conversationId: DConversationId, chatModeId: ChatModeId, fragments: (DMessageContentFragment | DMessageAttachmentFragment)[], metadata?: DMessageMetadata) => boolean; + onAction: (conversationId: DConversationId, chatExecuteMode: ChatExecuteMode, fragments: (DMessageContentFragment | DMessageAttachmentFragment)[], metadata?: DMessageMetadata) => boolean; onTextImagine: (conversationId: DConversationId, text: string) => void; setIsMulticast: (on: boolean) => void; sx?: SxProps; }) { // state - const [chatModeId, setChatModeId] = React.useState('generate-content'); const [composeText, debouncedText, setComposeText] = useDebouncer('', 300, 1200, true); const [micContinuation, setMicContinuation] = React.useState(false); const [speechInterimResult, setSpeechInterimResult] = React.useState(null); const [isDragging, setIsDragging] = React.useState(false); - const [chatModeMenuAnchor, setChatModeMenuAnchor] = React.useState(null); + const { chatExecuteMode, chatExecuteMenuShown, showChatExecuteMenu, chatExecuteMenuComponent } = + useChatExecuteMode(props.capabilityHasT2I, !!props.isMobile); // external state const { openPreferencesTab /*, setIsFocusedMode*/ } = useOptimaLayout(); @@ -148,7 +149,7 @@ export function Composer(props: { // composer-overlay: for the reply-to state, comes from the conversation overlay const { replyToGenerateText } = useChatOverlayStore(conversationOverlayStore, useShallow(store => ({ - replyToGenerateText: (chatModeId === 'generate-content' || chatModeId === 'generate-text-v1') ? store.replyToText?.trim() || null : null, + replyToGenerateText: (chatExecuteMode === 'generate-content' || chatExecuteMode === 'generate-text-v1') ? store.replyToText?.trim() || null : null, }))); // don't load URLs if the user is typing a command or there's no capability @@ -172,7 +173,7 @@ export function Composer(props: { const isDesktop = !props.isMobile; const noConversation = !targetConversationId; const noLLM = !props.chatLLM; - const showLLMAttachments = chatModeCanAttach(chatModeId); + const showLLMAttachments = chatExecuteModeCanAttach(chatExecuteMode); // tokens derived state @@ -221,11 +222,12 @@ export function Composer(props: { handleReplyToClear(); }, [attachmentsRemoveAll, handleReplyToClear, setComposeText]); - const handleSendAction = React.useCallback(async (_chatModeId: ChatModeId, composerText: string): Promise => { + + const handleSendAction = React.useCallback(async (_chatExecuteMode: ChatExecuteMode, composerText: string): Promise => { if (!isValidConversation(targetConversationId)) return false; // validate some chat mode inputs - const isDraw = _chatModeId === 'generate-image'; + const isDraw = _chatExecuteMode === 'generate-image'; const isBlank = !composerText.trim(); if (isDraw && isBlank) return false; @@ -235,7 +237,7 @@ export function Composer(props: { if (composerText) fragments.push(createTextContentFragment(composerText)); - const canAttach = chatModeCanAttach(_chatModeId); + const canAttach = chatExecuteModeCanAttach(_chatExecuteMode); if (canAttach) { const attachmentFragments = await attachmentsTakeAllFragments('global', 'app-chat'); fragments.push(...attachmentFragments); @@ -248,15 +250,16 @@ export function Composer(props: { // send the message - NOTE: if successful, the ownership of the fragments is transferred to the receiver, so we just clear them const metadata = replyToGenerateText ? { inReplyToText: replyToGenerateText } : undefined; - const enqueued = onAction(targetConversationId, _chatModeId, fragments, metadata); + const enqueued = onAction(targetConversationId, _chatExecuteMode, fragments, metadata); if (enqueued) handleClear(); return enqueued; }, [attachmentsTakeAllFragments, handleClear, onAction, replyToGenerateText, targetConversationId]); + const handleSendClicked = React.useCallback(async () => { - await handleSendAction(chatModeId, composeText); // 'chat/write/...' button - }, [chatModeId, composeText, handleSendAction]); + await handleSendAction(chatExecuteMode, composeText); // 'chat/write/...' button + }, [chatExecuteMode, composeText, handleSendAction]); const handleSendTextBeamClicked = React.useCallback(async () => { await handleSendAction('beam-content', composeText); // 'beam' button @@ -284,22 +287,6 @@ export function Composer(props: { }, [composeText, onTextImagine, setComposeText, targetConversationId]); - // Mode menu - - const handleModeSelectorHide = React.useCallback(() => { - setChatModeMenuAnchor(null); - }, []); - - const handleModeSelectorShow = React.useCallback((event: React.MouseEvent) => { - setChatModeMenuAnchor(anchor => anchor ? null : event.currentTarget); - }, []); - - const handleModeChange = React.useCallback((_chatModeId: ChatModeId) => { - handleModeSelectorHide(); - setChatModeId(_chatModeId); - }, [handleModeSelectorHide]); - - // Actiles const onActileCommandPaste = React.useCallback((item: ActileItem) => { @@ -380,12 +367,12 @@ export function Composer(props: { touchShiftEnter(); if (enterIsNewline ? e.shiftKey : !e.shiftKey) { if (!assistantAbortible) - await handleSendAction(chatModeId, composeText); // enter -> send + await handleSendAction(chatExecuteMode, composeText); // enter -> send return e.preventDefault(); } } - }, [actileInterceptKeydown, assistantAbortible, chatModeId, composeText, enterIsNewline, handleSendAction, touchAltEnter, touchCtrlEnter, touchShiftEnter]); + }, [actileInterceptKeydown, assistantAbortible, chatExecuteMode, composeText, enterIsNewline, handleSendAction, touchAltEnter, touchCtrlEnter, touchShiftEnter]); // Focus mode @@ -416,7 +403,7 @@ export function Composer(props: { if (autoSend) { if (notUserStop) playSoundUrl('/sounds/mic-off-mid.mp3'); - void handleSendAction(chatModeId, nextText); // fire/forget + void handleSendAction(chatExecuteMode, nextText); // fire/forget } else { if (!micContinuation && notUserStop) playSoundUrl('/sounds/mic-off-mid.mp3'); @@ -425,7 +412,7 @@ export function Composer(props: { setComposeText(nextText); } } - }, [chatModeId, composeText, composerTextAreaRef, handleSendAction, micContinuation, noConversation, setComposeText]); + }, [chatExecuteMode, composeText, composerTextAreaRef, handleSendAction, micContinuation, noConversation, setComposeText]); const { isSpeechEnabled, isSpeechError, isRecordingAudio, isRecordingSpeech, toggleRecording } = useSpeechRecognition(onSpeechResultCallback, chatMicTimeoutMs || 2000); @@ -547,11 +534,11 @@ export function Composer(props: { }, [attachAppendDataTransfer, eatDragEvent, setComposeText]); - const isText = chatModeId === 'generate-content' || chatModeId === 'generate-text-v1'; - const isTextBeam = chatModeId === 'beam-content'; - const isAppend = chatModeId === 'append-user'; - const isReAct = chatModeId === 'react-content'; - const isDraw = chatModeId === 'generate-image'; + const isText = chatExecuteMode === 'generate-content' || chatExecuteMode === 'generate-text-v1'; + const isTextBeam = chatExecuteMode === 'beam-content'; + const isAppend = chatExecuteMode === 'append-user'; + const isReAct = chatExecuteMode === 'react-content'; + const isDraw = chatExecuteMode === 'generate-image'; const showChatReplyTo = !!replyToGenerateText; const showChatExtras = isText && !showChatReplyTo; @@ -873,8 +860,8 @@ export function Composer(props: { {/* Mode expander */} @@ -911,14 +898,7 @@ export function Composer(props: { {/* Mode Menu */} - {!!chatModeMenuAnchor && ( - - )} + {chatExecuteMenuComponent} {/* Camera (when open) */} {cameraCaptureComponent} diff --git a/src/apps/chat/editors/_handleExecute.ts b/src/apps/chat/editors/_handleExecute.ts index ed7c4fafd..c2756ebdb 100644 --- a/src/apps/chat/editors/_handleExecute.ts +++ b/src/apps/chat/editors/_handleExecute.ts @@ -8,7 +8,7 @@ import { createTextContentFragment, isContentFragment, isTextPart } from '~/comm import { getConversationSystemPurposeId } from '~/common/stores/chat/store-chats'; import { getUXLabsHighPerformance } from '~/common/state/store-ux-labs'; -import type { ChatModeId } from '../AppChat'; +import type { ChatExecuteMode } from '../execute-mode/execute-mode.types'; import { getInstantAppChatPanesCount } from '../components/panes/usePanesManager'; import { textToDrawCommand } from '../commands/CommandsDraw'; @@ -19,7 +19,7 @@ import { runPersonaUpdatingState } from './chat-persona'; import { runReActUpdatingState } from './react-tangent'; -export async function _handleExecute(chatModeId: ChatModeId, conversationId: DConversationId, executeCallerNameDebug: string) { +export async function _handleExecute(chatExecuteMode: ChatExecuteMode, conversationId: DConversationId, executeCallerNameDebug: string) { // Handle missing conversation if (!conversationId) @@ -46,7 +46,7 @@ export async function _handleExecute(chatModeId: ChatModeId, conversationId: DCo // Handle unconfigured - if (!chatLLMId || !chatModeId) + if (!chatLLMId || !chatExecuteMode) return !chatLLMId ? 'err-no-chatllm' : 'err-no-chatmode'; // handle missing last user message (or fragment) @@ -71,7 +71,7 @@ export async function _handleExecute(chatModeId: ChatModeId, conversationId: DCo } // synchronous long-duration tasks, which update the state as they go - switch (chatModeId) { + switch (chatExecuteMode) { case 'generate-content': return await runPersonaUpdatingState(conversationId, chatLLMId); @@ -102,7 +102,7 @@ export async function _handleExecute(chatModeId: ChatModeId, conversationId: DCo return await runReActUpdatingState(cHandler, reactPrompt, chatLLMId); default: - console.log('Chat execute: issue running', chatModeId, conversationId, lastMessage); + console.log('Chat execute: issue running', chatExecuteMode, conversationId, lastMessage); return false; } } diff --git a/src/apps/chat/execute-mode/ExecuteModeMenu.tsx b/src/apps/chat/execute-mode/ExecuteModeMenu.tsx new file mode 100644 index 000000000..acaef3a1a --- /dev/null +++ b/src/apps/chat/execute-mode/ExecuteModeMenu.tsx @@ -0,0 +1,71 @@ +import * as React from 'react'; + +import { Box, MenuItem, Radio, Typography } from '@mui/joy'; + +import { CloseableMenu } from '~/common/components/CloseableMenu'; +import { KeyStroke, platformAwareKeystrokes } from '~/common/components/KeyStroke'; +import { useUIPreferencesStore } from '~/common/state/store-ui'; + +import type { ChatExecuteMode } from './execute-mode.types'; +import { ExecuteModeItems } from './execute-mode.items'; + + +export function ExecuteModeMenu(props: { + isMobile: boolean, + hasCapabilityT2I: boolean, + anchorEl: HTMLAnchorElement | null, + onClose: () => void, + chatExecuteMode: ChatExecuteMode, + onSetChatExecuteMode: (chatExecuteMode: ChatExecuteMode) => void, +}) { + + // external state + const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline); + + return ( + + + {/**/} + {/* Conversation Mode*/} + {/**/} + {/**/} + {/**/} + + {/* Items */} + {Object.entries(ExecuteModeItems) + .filter(([_key, data]) => !data.hideOnDesktop || props.isMobile) + .map(([key, data]) => + props.onSetChatExecuteMode(key as ChatExecuteMode)}> + + + + {data.label} + {data.description}{(data.requiresTTI && !props.hasCapabilityT2I) ? 'Unconfigured' : ''} + + {(key === props.chatExecuteMode || !!data.shortcut) && ( + + )} + + , + )} + + + ); +} + +function newLineShortcut(shortcut: string, enterIsNewLine: boolean) { + if (shortcut === 'ENTER') + return enterIsNewLine ? 'Shift + Enter' : 'Enter'; + return shortcut; +} diff --git a/src/apps/chat/execute-mode/execute-mode.items.ts b/src/apps/chat/execute-mode/execute-mode.items.ts new file mode 100644 index 000000000..d0b0b50d5 --- /dev/null +++ b/src/apps/chat/execute-mode/execute-mode.items.ts @@ -0,0 +1,50 @@ +import * as React from 'react'; + +import type { ChatExecuteMode } from './execute-mode.types'; + + +interface ModeDescription { + label: string; + description: string | React.JSX.Element; + canAttach?: boolean; + highlight?: boolean; + shortcut?: string; + hideOnDesktop?: boolean; + requiresTTI?: boolean; +} + + +export const ExecuteModeItems: { [key in ChatExecuteMode]: ModeDescription } = { + 'generate-content': { + label: 'Chat', + description: 'Persona replies', + canAttach: true, + }, + 'generate-text-v1': { + label: 'Chat (Stable)', + description: 'Model replies (stable)', + canAttach: true, + }, + 'beam-content': { + label: 'Beam', // Best of, Auto-Prime, Top Pick, Select Best + description: 'Combine multiple models', // Smarter: combine... + shortcut: 'Ctrl + Enter', + canAttach: true, + hideOnDesktop: true, + }, + 'append-user': { + label: 'Write', + description: 'Append a message', + shortcut: 'Alt + Enter', + canAttach: true, + }, + 'generate-image': { + label: 'Draw', + description: 'AI Image Generation', + requiresTTI: true, + }, + 'react-content': { + label: 'Reason + Act', // · α + description: 'Answer questions in multiple steps', + }, +}; diff --git a/src/apps/chat/execute-mode/execute-mode.types.ts b/src/apps/chat/execute-mode/execute-mode.types.ts new file mode 100644 index 000000000..13c25e67c --- /dev/null +++ b/src/apps/chat/execute-mode/execute-mode.types.ts @@ -0,0 +1,12 @@ +/** + * Mode: how to treat the input from the Composer + * Was: ChatModeId + */ +export type ChatExecuteMode = + | 'append-user' + | 'beam-content' + | 'generate-content' + | 'generate-image' + | 'generate-text-v1' + | 'react-content' + ; diff --git a/src/apps/chat/execute-mode/useChatExecuteMode.tsx b/src/apps/chat/execute-mode/useChatExecuteMode.tsx new file mode 100644 index 000000000..3803f0712 --- /dev/null +++ b/src/apps/chat/execute-mode/useChatExecuteMode.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; + +import type { ChatExecuteMode } from './execute-mode.types'; +import { ExecuteModeMenu } from './ExecuteModeMenu'; +import { ExecuteModeItems } from './execute-mode.items'; + + +export function chatExecuteModeCanAttach(chatExecuteMode: ChatExecuteMode) { + return !!ExecuteModeItems[chatExecuteMode]?.canAttach; +} + + +export function useChatExecuteMode(capabilityHasT2I: boolean, isMobile: boolean) { + + // state + const [chatExecuteMode, setChatExecuteMode] = React.useState('generate-content'); + const [chatExecuteModeMenuAnchor, setChatExecuteModeMenuAnchor] = React.useState(null); + + + const handleMenuHide = React.useCallback(() => setChatExecuteModeMenuAnchor(null), []); + + const handleMenuShow = React.useCallback((event: React.MouseEvent) => { + setChatExecuteModeMenuAnchor(anchor => anchor ? null : event.currentTarget); + }, []); + + const handleChangeMode = React.useCallback((mode: ChatExecuteMode) => { + handleMenuHide(); + setChatExecuteMode(mode); + }, [handleMenuHide]); + + + const chatExecuteMenuComponent = React.useMemo(() => !!chatExecuteModeMenuAnchor && ( + + ), [capabilityHasT2I, chatExecuteMode, chatExecuteModeMenuAnchor, handleMenuHide, handleChangeMode, isMobile]); + + + return { + chatExecuteMode, + chatExecuteMenuComponent, + chatExecuteMenuShown: !!chatExecuteModeMenuAnchor, + showChatExecuteMenu: handleMenuShow, + }; +}