From 55394629a386d0c94046391c1af64a41b0c76336 Mon Sep 17 00:00:00 2001 From: Jordan Hunt <65152573+jordanhunt22@users.noreply.github.com> Date: Mon, 22 Sep 2025 21:08:56 -0700 Subject: [PATCH] feat: allow configuring custom system prompts per chat --- app/components/chat/Chat.tsx | 3 + app/components/chat/MessageInput.tsx | 397 +++++++++++++------ app/lib/.server/chat.ts | 14 +- app/lib/.server/llm/convex-agent.ts | 13 +- app/lib/stores/customSystemPrompt.ts | 3 + app/lib/stores/startup/useInitialMessages.ts | 4 + convex/messages.test.ts | 22 + convex/messages.ts | 34 ++ convex/schema.ts | 1 + 9 files changed, 364 insertions(+), 127 deletions(-) create mode 100644 app/lib/stores/customSystemPrompt.ts diff --git a/app/components/chat/Chat.tsx b/app/components/chat/Chat.tsx index b19fb9ee4..bea37feff 100644 --- a/app/components/chat/Chat.tsx +++ b/app/components/chat/Chat.tsx @@ -47,6 +47,7 @@ import { useReferralCode, useReferralStats } from '~/lib/hooks/useReferralCode'; import { useUsage } from '~/lib/stores/usage'; import { hasAnyApiKeySet, hasApiKeySet } from '~/lib/common/apiKey'; import { chatSyncState } from '~/lib/stores/startup/chatSyncState'; +import { customSystemPromptStore } from '~/lib/stores/customSystemPrompt'; const logger = createScopedLogger('Chat'); @@ -348,6 +349,7 @@ export const Chat = memo( ); const characterCounts = chatContextManager.current.calculatePromptCharacterCounts(preparedMessages); + const customSystemPrompt = customSystemPromptStore.get(); return { messages: preparedMessages, @@ -367,6 +369,7 @@ export const Chat = memo( featureFlags: { enableResend, }, + customSystemPrompt, }; }, maxSteps: 64, diff --git a/app/components/chat/MessageInput.tsx b/app/components/chat/MessageInput.tsx index c85e82ef0..890f89455 100644 --- a/app/components/chat/MessageInput.tsx +++ b/app/components/chat/MessageInput.tsx @@ -35,11 +35,21 @@ import { toast } from 'sonner'; import { captureException } from '@sentry/remix'; import { Menu as MenuComponent, MenuItem as MenuItemComponent } from '@ui/Menu'; import { PencilSquareIcon } from '@heroicons/react/24/outline'; -import { ChatBubbleLeftIcon, DocumentArrowUpIcon, InformationCircleIcon } from '@heroicons/react/24/outline'; +import { + AdjustmentsHorizontalIcon, + ChatBubbleLeftIcon, + DocumentArrowUpIcon, + InformationCircleIcon, +} from '@heroicons/react/24/outline'; import { useAuth } from '@workos-inc/authkit-react'; import { useConvex } from 'convex/react'; +import { api } from '@convex/_generated/api'; +import { customSystemPromptStore } from '~/lib/stores/customSystemPrompt'; +import { chatIdStore } from '~/lib/stores/chatId'; +import { Modal } from '@ui/Modal'; const PROMPT_LENGTH_WARNING_THRESHOLD = 2000; +const CUSTOM_SYSTEM_PROMPT_MAX_LENGTH = 2000; type Highlight = { text: string; // must be lowercase @@ -116,11 +126,77 @@ export const MessageInput = memo(function MessageInput({ const chefAuthState = useChefAuth(); const selectedTeamSlug = useSelectedTeamSlug(); const convex = useConvex(); + const customSystemPrompt = useStore(customSystemPromptStore); + const chatId = useStore(chatIdStore); const textareaRef = useRef(null); const input = useStore(messageInputStore); + const [isCustomPromptModalOpen, setIsCustomPromptModalOpen] = useState(false); + const [customPromptDraft, setCustomPromptDraft] = useState(''); + const [isSavingCustomPrompt, setIsSavingCustomPrompt] = useState(false); + + useEffect(() => { + if (!isCustomPromptModalOpen) { + setCustomPromptDraft(customSystemPrompt ?? ''); + } + }, [customSystemPrompt, isCustomPromptModalOpen]); + + const customPromptLength = customPromptDraft.length; + const isCustomPromptTooLong = customPromptLength > CUSTOM_SYSTEM_PROMPT_MAX_LENGTH; + const hasCustomPrompt = !!customSystemPrompt && customSystemPrompt.trim().length > 0; + const customPromptButtonDisabled = !sessionId || !chatId; + + const openCustomPromptModal = useCallback(() => { + setCustomPromptDraft(customSystemPrompt ?? ''); + setIsCustomPromptModalOpen(true); + }, [customSystemPrompt]); + + const closeCustomPromptModal = useCallback(() => { + setIsCustomPromptModalOpen(false); + setCustomPromptDraft(customSystemPrompt ?? ''); + }, [customSystemPrompt]); + + const handleCustomPromptChange = useCallback((event: React.ChangeEvent) => { + setCustomPromptDraft(event.target.value); + }, []); + + const handleSaveCustomPrompt = useCallback(async () => { + if (isCustomPromptTooLong) { + toast.error(`Custom instructions must be ${CUSTOM_SYSTEM_PROMPT_MAX_LENGTH} characters or fewer.`); + return; + } + if (!chatId) { + toast.error('Chat Id is not available'); + return; + } + if (!sessionId) { + toast.error('Session is not ready yet.'); + return; + } + + try { + setIsSavingCustomPrompt(true); + const trimmedPrompt = customPromptDraft.trim(); + await convex.mutation(api.messages.setCustomSystemPrompt, { + id: chatId, + sessionId, + customSystemPrompt: trimmedPrompt.length > 0 ? trimmedPrompt : null, + }); + customSystemPromptStore.set(trimmedPrompt.length > 0 ? trimmedPrompt : undefined); + toast.success(trimmedPrompt.length > 0 ? 'Custom instructions saved' : 'Custom instructions cleared'); + setIsCustomPromptModalOpen(false); + setCustomPromptDraft(trimmedPrompt); + } catch (error) { + captureException(error); + console.error('Failed to update custom system prompt:', error); + toast.error('Failed to update custom instructions. Please try again.'); + } finally { + setIsSavingCustomPrompt(false); + } + }, [chatId, convex, customPromptDraft, isCustomPromptTooLong, sessionId]); + // Set the initial input value const [searchParams] = useSearchParams(); useEffect(() => { @@ -224,7 +300,7 @@ export const MessageInput = memo(function MessageInput({ } finally { setIsEnhancing(false); } - }, [input, convex]); + }, [input, convex, selectedTeamSlug]); // Helper to insert template and select '[...]' const insertTemplate = useCallback( @@ -251,136 +327,209 @@ export const MessageInput = memo(function MessageInput({ ); return ( -
-
-
- 0 - ? 'Request changes by sending another message…' - : 'Send a prompt for a new feature…' - : 'What app do you want to serve?' - } - disabled={disabled} - highlights={HIGHLIGHTS} - /> -
-
- {chefAuthState.kind === 'fullyLoggedIn' && ( - - )} - {!chatStarted && sessionId && ( - +
+
+
+ 0 + ? 'Request changes by sending another message…' + : 'Send a prompt for a new feature…' + : 'What app do you want to serve?' + } + disabled={disabled} + highlights={HIGHLIGHTS} /> - )} - {chatStarted && } - {input.length > 3 && input.length <= PROMPT_LENGTH_WARNING_THRESHOLD && } - {input.length > PROMPT_LENGTH_WARNING_THRESHOLD && } -
- {chefAuthState.kind === 'unauthenticated' && } - {chefAuthState.kind === 'fullyLoggedIn' && ( - - -
- ), - }} - placement="top-start" - > -
-

Use a recipe

- - - - - -
- insertTemplate('Make a collaborative text editor that ...')}> -
- - Make a collaborative text editor -
-
- insertTemplate('Add AI chat to ...')}> -
- - Add AI chat -
-
- insertTemplate('Add file upload to ...')}> -
- - Add file upload -
-
- insertTemplate('Add full text search to ...')}> -
- - Add full text search -
-
- +
+
{chefAuthState.kind === 'fullyLoggedIn' && ( - + )} + {!chatStarted && sessionId && ( + )} - + )} + {chefAuthState.kind === 'fullyLoggedIn' && ( + + )} +
-
+ {isCustomPromptModalOpen && ( + +
{ + event.preventDefault(); + void handleSaveCustomPrompt(); + }} + > +