diff --git a/app/components/@settings/tabs/features/FeaturesTab.tsx b/app/components/@settings/tabs/features/FeaturesTab.tsx index 3b14a7565d..bbdfa1f290 100644 --- a/app/components/@settings/tabs/features/FeaturesTab.tsx +++ b/app/components/@settings/tabs/features/FeaturesTab.tsx @@ -111,10 +111,12 @@ export default function FeaturesTab() { isLatestBranch, contextOptimizationEnabled, eventLogs, + frameworkLock, setAutoSelectTemplate, enableLatestBranch, enableContextOptimization, setEventLogs, + setFrameworkLock, setPromptId, promptId, } = useSettings(); @@ -169,12 +171,17 @@ export default function FeaturesTab() { toast.success(`Event logging ${enabled ? 'enabled' : 'disabled'}`); break; } + case 'frameworkLock': { + setFrameworkLock(enabled); + toast.success(`Framework lock ${enabled ? 'enabled' : 'disabled'}`); + break; + } default: break; } }, - [enableLatestBranch, setAutoSelectTemplate, enableContextOptimization, setEventLogs], + [enableLatestBranch, setAutoSelectTemplate, enableContextOptimization, setEventLogs, setFrameworkLock], ); const features = { @@ -212,7 +219,17 @@ export default function FeaturesTab() { tooltip: 'Enabled by default to record detailed logs of system events and user actions', }, ], - beta: [], + beta: [ + { + id: 'frameworkLock', + title: 'Framework Lock', + description: 'Keep the assistant aligned with the detected stack unless you ask to change it', + icon: 'i-ph:lock-closed', + enabled: frameworkLock, + beta: true, + tooltip: 'Uses package.json to detect the framework and keeps suggestions within that stack', + }, + ], }; return ( diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx index c4706e1764..6e31b42722 100644 --- a/app/components/chat/Chat.client.tsx +++ b/app/components/chat/Chat.client.tsx @@ -2,13 +2,13 @@ import { useStore } from '@nanostores/react'; import type { Message } from 'ai'; import { useChat } from '@ai-sdk/react'; import { useAnimate } from 'framer-motion'; -import { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'react-toastify'; import { useMessageParser, usePromptEnhancer, useShortcuts } from '~/lib/hooks'; import { description, useChatHistory } from '~/lib/persistence'; import { chatStore } from '~/lib/stores/chat'; import { workbenchStore } from '~/lib/stores/workbench'; -import { DEFAULT_MODEL, DEFAULT_PROVIDER, PROMPT_COOKIE_KEY, PROVIDER_LIST } from '~/utils/constants'; +import { DEFAULT_MODEL, DEFAULT_PROVIDER, PROMPT_COOKIE_KEY, PROVIDER_LIST, WORK_DIR } from '~/utils/constants'; import { cubicEasingFn } from '~/utils/easings'; import { createScopedLogger, renderLogger } from '~/utils/logger'; import { BaseChat } from './BaseChat'; @@ -28,6 +28,7 @@ import type { ElementInfo } from '~/components/workbench/Inspector'; import type { TextUIPart, FileUIPart, Attachment } from '@ai-sdk/ui-utils'; import { useMCPStore } from '~/lib/stores/mcp'; import type { LlmErrorAlertType } from '~/types/actions'; +import { detectFrameworkFromFiles } from '~/utils/framework'; const logger = createScopedLogger('Chat'); @@ -100,7 +101,28 @@ export const ChatImpl = memo( (project) => project.id === supabaseConn.selectedProjectId, ); const supabaseAlert = useStore(workbenchStore.supabaseAlert); - const { activeProviders, promptId, autoSelectTemplate, contextOptimizationEnabled } = useSettings(); + const { activeProviders, promptId, autoSelectTemplate, contextOptimizationEnabled, frameworkLock } = useSettings(); + const packageJsonEntry = files[`${WORK_DIR}/package.json`] ?? files['package.json']; + const packageJsonContent = useMemo(() => { + if (!packageJsonEntry || packageJsonEntry.type !== 'file') { + return undefined; + } + + return packageJsonEntry.content; + }, [packageJsonEntry]); + const frameworkHint = useMemo(() => { + if (!frameworkLock || !packageJsonContent) { + return undefined; + } + + const detected = detectFrameworkFromFiles(files); + + if (!detected) { + return undefined; + } + + return `Detected framework: ${detected}. Stay within this stack unless the user explicitly asks to change frameworks.`; + }, [frameworkLock, packageJsonContent]); const [llmErrorAlert, setLlmErrorAlert] = useState(undefined); const [model, setModel] = useState(() => { const savedModel = Cookies.get('selectedModel'); @@ -140,6 +162,7 @@ export const ChatImpl = memo( contextOptimization: contextOptimizationEnabled, chatMode, designScheme, + frameworkHint, supabase: { isConnected: supabaseConn.isConnected, hasSelectedProject: !!selectedProject, diff --git a/app/lib/.server/llm/stream-text.ts b/app/lib/.server/llm/stream-text.ts index 40774a8d07..936c402f3f 100644 --- a/app/lib/.server/llm/stream-text.ts +++ b/app/lib/.server/llm/stream-text.ts @@ -65,6 +65,7 @@ export async function streamText(props: { messageSliceId?: number; chatMode?: 'discuss' | 'build'; designScheme?: DesignScheme; + frameworkHint?: string; }) { const { messages, @@ -79,6 +80,7 @@ export async function streamText(props: { summary, chatMode, designScheme, + frameworkHint, } = props; let currentModel = DEFAULT_MODEL; let currentProvider = DEFAULT_PROVIDER.name; @@ -219,6 +221,16 @@ export async function streamText(props: { console.log('No locked files found from any source for prompt.'); } + if (frameworkHint) { + systemPrompt = `${systemPrompt} + + PROJECT FRAMEWORK (DETECTED): + ${sanitizeText(frameworkHint)} + IMPORTANT: Stay within this framework unless the user explicitly asks to change stacks. + --- + `; + } + logger.info(`Sending llm call to ${provider.name} with model ${modelDetails.name}`); // Log reasoning model detection and token parameters diff --git a/app/lib/hooks/useSettings.ts b/app/lib/hooks/useSettings.ts index e5aceb2f9d..2d9e913825 100644 --- a/app/lib/hooks/useSettings.ts +++ b/app/lib/hooks/useSettings.ts @@ -7,6 +7,7 @@ import { latestBranchStore, autoSelectStarterTemplate, enableContextOptimizationStore, + frameworkLockStore, tabConfigurationStore, resetTabConfiguration as resetTabConfig, updateProviderSettings as updateProviderSettingsStore, @@ -15,6 +16,7 @@ import { updateContextOptimization, updateEventLogs, updatePromptId, + updateFrameworkLock, } from '~/lib/stores/settings'; import { useCallback, useEffect, useState } from 'react'; import Cookies from 'js-cookie'; @@ -58,6 +60,8 @@ export interface UseSettingsReturn { setAutoSelectTemplate: (enabled: boolean) => void; contextOptimizationEnabled: boolean; enableContextOptimization: (enabled: boolean) => void; + frameworkLock: boolean; + setFrameworkLock: (enabled: boolean) => void; // Tab configuration tabConfiguration: TabWindowConfig; @@ -78,6 +82,7 @@ export function useSettings(): UseSettingsReturn { const autoSelectTemplate = useStore(autoSelectStarterTemplate); const [activeProviders, setActiveProviders] = useState([]); const contextOptimizationEnabled = useStore(enableContextOptimizationStore); + const frameworkLock = useStore(frameworkLockStore); const tabConfiguration = useStore(tabConfigurationStore); const [settings, setSettings] = useState(() => { const storedSettings = getLocalStorage('settings'); @@ -143,6 +148,11 @@ export function useSettings(): UseSettingsReturn { logStore.logSystem(`Context optimization ${enabled ? 'enabled' : 'disabled'}`); }, []); + const setFrameworkLock = useCallback((enabled: boolean) => { + updateFrameworkLock(enabled); + logStore.logSystem(`Framework lock ${enabled ? 'enabled' : 'disabled'}`); + }, []); + const setTheme = useCallback( (theme: Settings['theme']) => { saveSettings({ theme }); @@ -197,6 +207,8 @@ export function useSettings(): UseSettingsReturn { setAutoSelectTemplate, contextOptimizationEnabled, enableContextOptimization, + frameworkLock, + setFrameworkLock, setTheme, setLanguage, setNotifications, diff --git a/app/lib/stores/settings.ts b/app/lib/stores/settings.ts index fb123e6a94..195f51f5a6 100644 --- a/app/lib/stores/settings.ts +++ b/app/lib/stores/settings.ts @@ -258,6 +258,7 @@ const SETTINGS_KEYS = { EVENT_LOGS: 'isEventLogsEnabled', PROMPT_ID: 'promptId', DEVELOPER_MODE: 'isDeveloperMode', + FRAMEWORK_LOCK: 'frameworkLock', } as const; // Initialize settings from localStorage or defaults @@ -287,6 +288,7 @@ const getInitialSettings = () => { eventLogs: getStoredBoolean(SETTINGS_KEYS.EVENT_LOGS, true), promptId: isBrowser ? localStorage.getItem(SETTINGS_KEYS.PROMPT_ID) || 'default' : 'default', developerMode: getStoredBoolean(SETTINGS_KEYS.DEVELOPER_MODE, false), + frameworkLock: getStoredBoolean(SETTINGS_KEYS.FRAMEWORK_LOCK, false), }; }; @@ -298,6 +300,7 @@ export const autoSelectStarterTemplate = atom(initialSettings.autoSelec export const enableContextOptimizationStore = atom(initialSettings.contextOptimization); export const isEventLogsEnabled = atom(initialSettings.eventLogs); export const promptStore = atom(initialSettings.promptId); +export const frameworkLockStore = atom(initialSettings.frameworkLock); // Helper functions to update settings with persistence export const updateLatestBranch = (enabled: boolean) => { @@ -325,6 +328,11 @@ export const updatePromptId = (id: string) => { localStorage.setItem(SETTINGS_KEYS.PROMPT_ID, id); }; +export const updateFrameworkLock = (enabled: boolean) => { + frameworkLockStore.set(enabled); + localStorage.setItem(SETTINGS_KEYS.FRAMEWORK_LOCK, JSON.stringify(enabled)); +}; + // Initialize tab configuration from localStorage or defaults const getInitialTabConfiguration = (): TabWindowConfig => { const defaultConfig: TabWindowConfig = { diff --git a/app/routes/api.chat.ts b/app/routes/api.chat.ts index 73f9176305..80922012e8 100644 --- a/app/routes/api.chat.ts +++ b/app/routes/api.chat.ts @@ -48,24 +48,34 @@ async function chatAction({ context, request }: ActionFunctionArgs) { }, }); - const { messages, files, promptId, contextOptimization, supabase, chatMode, designScheme, maxLLMSteps } = - await request.json<{ - messages: Messages; - files: any; - promptId?: string; - contextOptimization: boolean; - chatMode: 'discuss' | 'build'; - designScheme?: DesignScheme; - supabase?: { - isConnected: boolean; - hasSelectedProject: boolean; - credentials?: { - anonKey?: string; - supabaseUrl?: string; - }; + const { + messages, + files, + promptId, + contextOptimization, + supabase, + chatMode, + designScheme, + maxLLMSteps, + frameworkHint, + } = await request.json<{ + messages: Messages; + files: any; + promptId?: string; + contextOptimization: boolean; + chatMode: 'discuss' | 'build'; + designScheme?: DesignScheme; + frameworkHint?: string; + supabase?: { + isConnected: boolean; + hasSelectedProject: boolean; + credentials?: { + anonKey?: string; + supabaseUrl?: string; }; - maxLLMSteps: number; - }>(); + }; + maxLLMSteps: number; + }>(); const cookieHeader = request.headers.get('Cookie'); const apiKeys = JSON.parse(parseCookies(cookieHeader || '').apiKeys || '{}'); @@ -278,6 +288,7 @@ async function chatAction({ context, request }: ActionFunctionArgs) { contextFiles: filteredFiles, chatMode, designScheme, + frameworkHint, summary, messageSliceId, }); @@ -319,6 +330,7 @@ async function chatAction({ context, request }: ActionFunctionArgs) { contextFiles: filteredFiles, chatMode, designScheme, + frameworkHint, summary, messageSliceId, }); diff --git a/app/utils/framework.ts b/app/utils/framework.ts new file mode 100644 index 0000000000..a9f9757ba7 --- /dev/null +++ b/app/utils/framework.ts @@ -0,0 +1,116 @@ +import type { FileMap } from '~/lib/stores/files'; +import { WORK_DIR } from '~/utils/constants'; + +const PACKAGE_JSON_PATH = `${WORK_DIR}/package.json`; +const FALLBACK_PACKAGE_JSON_PATH = 'package.json'; + +const getPackageJsonContent = (files?: FileMap): string | undefined => { + if (!files) { + return undefined; + } + + const direct = files[PACKAGE_JSON_PATH] || files[FALLBACK_PACKAGE_JSON_PATH]; + + if (!direct || direct.type !== 'file') { + return undefined; + } + + if (typeof direct.content !== 'string') { + return undefined; + } + + return direct.content; +}; + +export const detectFrameworkFromFiles = (files?: FileMap): string | null => { + const content = getPackageJsonContent(files); + + if (!content) { + return null; + } + + try { + const pkg = JSON.parse(content) as { + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + }; + + const deps = { + ...(pkg.dependencies || {}), + ...(pkg.devDependencies || {}), + ...(pkg.peerDependencies || {}), + }; + + const has = (name: string) => Boolean(deps[name]); + const hasPrefix = (prefix: string) => Object.keys(deps).some((key) => key.startsWith(prefix)); + + if (has('expo')) { + return 'Expo (React Native)'; + } + + if (has('react-native')) { + return 'React Native'; + } + + if (has('next')) { + return 'Next.js'; + } + + if (hasPrefix('@remix-run/')) { + return 'Remix'; + } + + if (has('astro')) { + return 'Astro'; + } + + if (has('@sveltejs/kit')) { + return 'SvelteKit'; + } + + if (has('svelte')) { + return 'Svelte'; + } + + if (has('nuxt')) { + return 'Nuxt'; + } + + if (has('vue')) { + return 'Vue'; + } + + if (has('@angular/core')) { + return 'Angular'; + } + + if (has('@builder.io/qwik')) { + return 'Qwik'; + } + + if (has('solid-js')) { + return 'SolidJS'; + } + + if (has('vite') && has('react')) { + return 'React + Vite'; + } + + if (has('react')) { + return 'React'; + } + + if (has('preact')) { + return 'Preact'; + } + + if (has('vite')) { + return 'Vite'; + } + } catch (error) { + console.warn('Failed to parse package.json for framework detection', error); + } + + return null; +};