diff --git a/src/background/handlers/ProvidersHandler.ts b/src/background/handlers/ProvidersHandler.ts index f7de2227..1378f0a7 100644 --- a/src/background/handlers/ProvidersHandler.ts +++ b/src/background/handlers/ProvidersHandler.ts @@ -3,6 +3,7 @@ import { PortMessage } from '@/lib/runtime/PortMessaging' import { LLMSettingsReader } from '@/lib/llm/settings/LLMSettingsReader' import { langChainProvider } from '@/lib/llm/LangChainProvider' import { BrowserOSProvidersConfigSchema, BROWSEROS_PREFERENCE_KEYS } from '@/lib/llm/settings/browserOSTypes' +import { clearCustomSystemPromptCache } from '@/lib/llm/settings/customSystemPrompt' import { Logging } from '@/lib/utils/Logging' import { PortManager } from '@/background/router/PortManager' @@ -69,7 +70,8 @@ export class ProvidersHandler { if (payload.providers) { payload.providers = payload.providers.map((p: any) => ({ ...p, - isDefault: p.isDefault !== undefined ? p.isDefault : (p.id === 'browseros') + isDefault: p.isDefault !== undefined ? p.isDefault : (p.id === 'browseros'), + systemPrompt: typeof p.systemPrompt === 'string' ? p.systemPrompt : '' })) } const config = BrowserOSProvidersConfigSchema.parse(payload) @@ -116,6 +118,7 @@ export class ProvidersHandler { const success = browserOSSuccess || storageSuccess if (success) { try { langChainProvider.clearCache() } catch (_) {} + clearCustomSystemPromptCache() this.lastProvidersConfigJson = configStr this.broadcastProvidersConfig(config) diff --git a/src/lib/agent/BrowserAgent.ts b/src/lib/agent/BrowserAgent.ts index f326fdb7..61c90b6f 100644 --- a/src/lib/agent/BrowserAgent.ts +++ b/src/lib/agent/BrowserAgent.ts @@ -13,6 +13,7 @@ import { Runnable } from "@langchain/core/runnables"; import { BaseLanguageModelInput } from "@langchain/core/language_models/base"; import { z } from "zod"; import { getLLM } from "@/lib/llm/LangChainProvider"; +import { applyCustomSystemPrompt } from "@/lib/llm/settings/customSystemPrompt"; import BrowserPage from "@/lib/browser/BrowserPage"; import { PubSub } from "@/lib/pubsub"; import { PubSubChannel } from "@/lib/pubsub/PubSubChannel"; @@ -733,7 +734,7 @@ export class BrowserAgent { // Get numbeer of tokens in full history // System prompt for planner - const systemPrompt = generatePlannerPrompt(this.toolDescriptions || ""); + const systemPrompt = await applyCustomSystemPrompt(generatePlannerPrompt(this.toolDescriptions || "")); const systemPromptTokens = TokenCounter.countMessage(new SystemMessage(systemPrompt)); const fullHistoryTokens = TokenCounter.countMessage(new HumanMessage(fullHistory)); @@ -842,7 +843,7 @@ ${fullHistory} ): Promise { // Use the current iteration message manager from execution context const executorMM = new MessageManager(); - const systemPrompt = generateExecutorPrompt(this._buildExecutionContext()); + const systemPrompt = await applyCustomSystemPrompt(generateExecutorPrompt(this._buildExecutionContext())); const systemPromptTokens = TokenCounter.countMessage(new SystemMessage(systemPrompt)); executorMM.addSystem(systemPrompt); const currentIterationToolMessages: string[] = []; @@ -1359,7 +1360,7 @@ ${fullHistory} // Get accumulated execution history from all iterations let fullHistory = this._buildPlannerExecutionHistory(); - const systemPrompt = generatePredefinedPlannerPrompt(this.toolDescriptions || ""); + const systemPrompt = await applyCustomSystemPrompt(generatePredefinedPlannerPrompt(this.toolDescriptions || "")); const systemPromptTokens = TokenCounter.countMessage(new SystemMessage(systemPrompt)); const fullHistoryTokens = TokenCounter.countMessage(new HumanMessage(fullHistory)); Logging.log("BrowserAgent", `Full execution history tokens: ${fullHistoryTokens}`, "info"); @@ -1538,7 +1539,7 @@ ${fullHistory} intelligence: 'high' }); const structuredLLM = llm.withStructuredOutput(ExecutionHistorySummarySchema); - const systemPrompt = generateExecutionHistorySummaryPrompt(); + const systemPrompt = await applyCustomSystemPrompt(generateExecutionHistorySummaryPrompt()); const userPrompt = `Execution History: ${historyWithoutSections}`; const messages = [ new SystemMessage(systemPrompt), diff --git a/src/lib/agent/LocalAgent.ts b/src/lib/agent/LocalAgent.ts index 4662f21b..dc2dc56e 100644 --- a/src/lib/agent/LocalAgent.ts +++ b/src/lib/agent/LocalAgent.ts @@ -13,6 +13,7 @@ import { Runnable } from "@langchain/core/runnables"; import { BaseLanguageModelInput } from "@langchain/core/language_models/base"; import { z } from "zod"; import { getLLM } from "@/lib/llm/LangChainProvider"; +import { applyCustomSystemPrompt } from "@/lib/llm/settings/customSystemPrompt"; import BrowserPage from "@/lib/browser/BrowserPage"; import { PubSub } from "@/lib/pubsub"; import { PubSubChannel } from "@/lib/pubsub/PubSubChannel"; @@ -691,7 +692,7 @@ export class LocalAgent { // Get numbeer of tokens in full history // System prompt for planner - const systemPrompt = generatePlannerPrompt(this.toolDescriptions || ""); + const systemPrompt = await applyCustomSystemPrompt(generatePlannerPrompt(this.toolDescriptions || "")); const systemPromptTokens = TokenCounter.countMessage(new SystemMessage(systemPrompt)); const fullHistoryTokens = TokenCounter.countMessage(new HumanMessage(fullHistory)); @@ -810,7 +811,7 @@ Continue upon the previous steps what has been done so far and suggest next step ): Promise { // Use the current iteration message manager from execution context const executorMM = new MessageManager(); - const systemPrompt = generateExecutorPrompt(this._buildExecutionContext()); + const systemPrompt = await applyCustomSystemPrompt(generateExecutorPrompt(this._buildExecutionContext())); const systemPromptTokens = TokenCounter.countMessage(new SystemMessage(systemPrompt)); executorMM.addSystem(systemPrompt); const currentIterationToolMessages: string[] = []; @@ -1327,7 +1328,7 @@ Continue upon the previous steps what has been done so far and suggest next step // Get accumulated execution history from all iterations let fullHistory = this._buildPlannerExecutionHistory(); - const systemPrompt = generatePredefinedPlannerPrompt(this.toolDescriptions || ""); + const systemPrompt = await applyCustomSystemPrompt(generatePredefinedPlannerPrompt(this.toolDescriptions || "")); const systemPromptTokens = TokenCounter.countMessage(new SystemMessage(systemPrompt)); const fullHistoryTokens = TokenCounter.countMessage(new HumanMessage(fullHistory)); Logging.log("LocalAgent", `Full execution history tokens: ${fullHistoryTokens}`, "info"); @@ -1509,7 +1510,7 @@ Continue upon your previous steps what has been done so far and suggest next ste temperature: 0.2, maxTokens: 4096, }); - const systemPrompt = generateExecutionHistorySummaryPrompt(); + const systemPrompt = await applyCustomSystemPrompt(generateExecutionHistorySummaryPrompt()); const userPrompt = `Execution History: ${historyWithoutSections}`; const messages = [ new SystemMessage(systemPrompt), diff --git a/src/lib/llm/settings/LLMSettingsReader.ts b/src/lib/llm/settings/LLMSettingsReader.ts index 741d03ba..e5bc26ab 100644 --- a/src/lib/llm/settings/LLMSettingsReader.ts +++ b/src/lib/llm/settings/LLMSettingsReader.ts @@ -9,6 +9,7 @@ import { createDefaultBrowserOSProvider, createDefaultProvidersConfig } from './browserOSTypes' +import { setCachedDefaultProvider, clearCustomSystemPromptCache } from './customSystemPrompt' // Type definitions for chrome.browserOS API (callback-based) declare global { @@ -57,6 +58,7 @@ export class LLMSettingsReader { ...provider, isDefault: provider.id === defaultProviderId, isBuiltIn: provider.isBuiltIn ?? false, + systemPrompt: typeof provider.systemPrompt === 'string' ? provider.systemPrompt : '', createdAt: provider.createdAt ?? new Date().toISOString(), updatedAt: provider.updatedAt ?? new Date().toISOString() })) @@ -95,11 +97,14 @@ export class LLMSettingsReader { const provider = config.providers.find(p => p.id === config.defaultProviderId) || config.providers[0] || this.getDefaultBrowserOSProvider() + setCachedDefaultProvider(provider) return provider } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) Logging.log('LLMSettingsReader', `Failed to read settings: ${errorMessage}`, 'error') - return this.getDefaultBrowserOSProvider() + const fallback = this.getDefaultBrowserOSProvider() + setCachedDefaultProvider(fallback) + return fallback } } @@ -110,6 +115,10 @@ export class LLMSettingsReader { try { const config = await this.readProvidersConfig() if (config) { + const defaultProvider = config.providers.find(p => p.id === config.defaultProviderId) + || config.providers[0] + || null + setCachedDefaultProvider(defaultProvider) return config } } catch (error) { @@ -117,7 +126,9 @@ export class LLMSettingsReader { Logging.log('LLMSettingsReader', `Failed to read providers: ${errorMessage}`, 'error') } - return createDefaultProvidersConfig() + const fallback = createDefaultProvidersConfig() + setCachedDefaultProvider(fallback.providers[0] || null) + return fallback } /** @@ -300,6 +311,11 @@ export class LLMSettingsReader { const success = browserOSSuccess || storageSuccess if (!success) { Logging.log('LLMSettingsReader', 'Failed to save to any storage mechanism', 'error') + } else { + const defaultProvider = normalized.providers.find(p => p.id === normalized.defaultProviderId) + || normalized.providers[0] + || null + setCachedDefaultProvider(defaultProvider) } return success } diff --git a/src/lib/llm/settings/browserOSTypes.ts b/src/lib/llm/settings/browserOSTypes.ts index 4595d894..20132ae8 100644 --- a/src/lib/llm/settings/browserOSTypes.ts +++ b/src/lib/llm/settings/browserOSTypes.ts @@ -48,6 +48,7 @@ export const BrowserOSProviderSchema = z.object({ baseUrl: z.string().optional(), // API base URL apiKey: z.string().optional(), // API key for authentication modelId: z.string().optional(), // Model identifier + systemPrompt: z.string().optional(), // Custom system prompt override capabilities: ProviderCapabilitiesSchema.optional(), // Provider capabilities modelConfig: ModelConfigSchema.optional(), // Model configuration createdAt: z.string(), // ISO timestamp of creation @@ -94,6 +95,7 @@ export function createDefaultBrowserOSProvider(): BrowserOSProvider { type: 'browseros', isDefault: true, isBuiltIn: true, + systemPrompt: '', createdAt: timestamp, updatedAt: timestamp } diff --git a/src/lib/llm/settings/customSystemPrompt.ts b/src/lib/llm/settings/customSystemPrompt.ts new file mode 100644 index 00000000..ea5a3542 --- /dev/null +++ b/src/lib/llm/settings/customSystemPrompt.ts @@ -0,0 +1,105 @@ +import { BrowserOSProvider, BrowserOSProvidersConfig } from './browserOSTypes' +import { LLMSettingsReader } from './LLMSettingsReader' + +let cachedBrowserOSProvider: BrowserOSProvider | null = null +let cachedDefaultProvider: BrowserOSProvider | null = null + +const cloneProvider = (provider: BrowserOSProvider | null): BrowserOSProvider | null => { + if (!provider) return null + return { ...provider } +} + +const extractDefaultProvider = (config: BrowserOSProvidersConfig | null): BrowserOSProvider | null => { + if (!config) return null + const provider = config.providers.find(p => p.id === config.defaultProviderId) || config.providers[0] || null + return provider ? { ...provider } : null +} + +/** + * Extract the BrowserOS provider specifically (where custom prompts are stored) + */ +const extractBrowserOSProvider = (config: BrowserOSProvidersConfig | null): BrowserOSProvider | null => { + if (!config) return null + // Look for the BrowserOS provider specifically (it has type 'browseros') + const browserOSProvider = config.providers.find(p => p.type === 'browseros') + return browserOSProvider ? { ...browserOSProvider } : null +} + +const readDefaultProvider = async (): Promise => { + try { + const config = await LLMSettingsReader.readAllProviders() + return extractDefaultProvider(config) + } catch (error) { + console.warn('[customSystemPrompt] Failed to read providers config:', error) + return null + } +} + +/** + * Read the BrowserOS provider specifically (for custom system prompts) + */ +const readBrowserOSProvider = async (): Promise => { + try { + const config = await LLMSettingsReader.readAllProviders() + return extractBrowserOSProvider(config) + } catch (error) { + console.warn('[customSystemPrompt] Failed to read BrowserOS provider:', error) + return null + } +} + +export const clearCustomSystemPromptCache = (): void => { + cachedBrowserOSProvider = null + cachedDefaultProvider = null +} + +export const setCachedDefaultProvider = (provider: BrowserOSProvider | null): void => { + cachedDefaultProvider = cloneProvider(provider) + // If this is a BrowserOS provider, also cache it as the BrowserOS provider + if (provider && provider.type === 'browseros') { + cachedBrowserOSProvider = cloneProvider(provider) + } +} + +export const getCachedDefaultProvider = async (): Promise => { + if (cachedDefaultProvider) { + return cachedDefaultProvider + } + const provider = await readDefaultProvider() + cachedDefaultProvider = cloneProvider(provider) + return cachedDefaultProvider +} + +/** + * Get the cached BrowserOS provider (where custom prompts are stored) + */ +const getCachedBrowserOSProvider = async (): Promise => { + if (cachedBrowserOSProvider) { + return cachedBrowserOSProvider + } + const provider = await readBrowserOSProvider() + cachedBrowserOSProvider = cloneProvider(provider) + return cachedBrowserOSProvider +} + +export const applyCustomSystemPrompt = async (basePrompt: string): Promise => { + try { + // Always read custom prompt from the BrowserOS provider, regardless of which provider is currently active + // Custom prompts are stored in the BrowserOS provider configuration even when using other providers + const browserOSProvider = await getCachedBrowserOSProvider() + if (!browserOSProvider) { + // No BrowserOS provider found, just return the base prompt + return basePrompt + } + + const customPrompt = (browserOSProvider.systemPrompt ?? '').trim() + if (!customPrompt) { + return basePrompt + } + + return `${customPrompt}\n\n${basePrompt}` + } catch (error) { + console.warn('[customSystemPrompt] Failed to apply custom prompt:', error) + return basePrompt + } +} diff --git a/src/options/OptionsNew.tsx b/src/options/OptionsNew.tsx index 795cb307..9429560b 100644 --- a/src/options/OptionsNew.tsx +++ b/src/options/OptionsNew.tsx @@ -1,14 +1,15 @@ -import React, { useState, useEffect } from 'react' +import React, { useCallback, useMemo, useState, useEffect } from 'react' import { SettingsLayout } from './components/SettingsLayout' import { LLMProvidersSection } from './components/LLMProvidersSection' +import { BrowserOSPromptEditor } from './components/BrowserOSPromptEditor' import { ProviderTemplates } from './components/ProviderTemplates' import { ConfiguredModelsList } from './components/ConfiguredModelsList' import { AddProviderModal } from './components/AddProviderModal' import { useBrowserOSPrefs } from './hooks/useBrowserOSPrefs' -import { useOptionsStore } from './stores/optionsStore' import { useSettingsStore } from '@/sidepanel/stores/settingsStore' import { testLLMProvider } from './services/llm-test-service' import { LLMProvider, TestResult } from './types/llm-settings' +import { Bot, FileText } from 'lucide-react' import './styles.css' export function OptionsNew() { @@ -52,12 +53,28 @@ export function OptionsNew() { return () => window.removeEventListener('storage', handleStorageChange) }, [theme]) - const handleUseTemplate = (template: LLMProvider) => { + const handleUseTemplate = useCallback((template: LLMProvider) => { setEditingProvider(template) setIsAddingProvider(true) - } + }, [setEditingProvider, setIsAddingProvider]) - const handleSaveProvider = async (provider: Partial) => { + const browserOSProvider = useMemo( + () => providers.find(provider => provider.id === 'browseros'), + [providers] + ) + + const handleSaveBrowserOSPrompt = useCallback(async (prompt: string) => { + const currentBrowserOSProvider = providers.find(provider => provider.id === 'browseros') + if (!currentBrowserOSProvider) { + throw new Error('BrowserOS provider not found') + } + await updateProvider({ + ...currentBrowserOSProvider, + systemPrompt: prompt + }) + }, [providers, updateProvider]) + + const handleSaveProvider = useCallback(async (provider: Partial) => { try { if (editingProvider?.id) { await updateProvider(provider as LLMProvider) @@ -70,9 +87,9 @@ export function OptionsNew() { // Show error to user - the error will be displayed in the modal throw error } - } + }, [editingProvider, updateProvider, addProvider]) - const handleTestProvider = async (providerId: string) => { + const handleTestProvider = useCallback(async (providerId: string) => { const provider = providers.find(p => p.id === providerId) if (!provider) return @@ -98,40 +115,72 @@ export function OptionsNew() { } })) } - } + }, [providers, testLLMProvider]) - return ( - -
- setIsAddingProvider(true)} - /> + const sections = useMemo(() => [ + { + id: 'browseros-ai', + label: 'BrowserOS AI', + icon: Bot, + content: ( +
+ setIsAddingProvider(true)} + /> + + - - - { - setEditingProvider(provider) - setIsAddingProvider(true) - }} - onDelete={deleteProvider} - onClearTestResult={(providerId) => { - setTestResults(prev => { - const newResults = { ...prev } - delete newResults[providerId] - return newResults - }) - }} + { + setEditingProvider(provider) + setIsAddingProvider(true) + }} + onDelete={deleteProvider} + onClearTestResult={(providerId) => { + setTestResults(prev => { + const newResults = { ...prev } + delete newResults[providerId] + return newResults + }) + }} + /> +
+ ) + }, + { + id: 'browseros-system-prompt', + label: 'BrowserOS system prompt', + icon: FileText, + content: ( + -
+ ) + } + ], [ + browserOSProvider, + defaultProvider, + providers, + setDefaultProvider, + testResults, + handleUseTemplate, + handleSaveBrowserOSPrompt, + handleTestProvider, + deleteProvider + ]) + + return ( + <> + - + ) -} \ No newline at end of file +} diff --git a/src/options/components/BrowserOSPromptEditor.tsx b/src/options/components/BrowserOSPromptEditor.tsx new file mode 100644 index 00000000..ab5e9aa6 --- /dev/null +++ b/src/options/components/BrowserOSPromptEditor.tsx @@ -0,0 +1,171 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { LLMProvider } from '../types/llm-settings' + +const browserOSLogo = + typeof chrome !== 'undefined' && chrome?.runtime?.getURL + ? chrome.runtime.getURL('assets/browseros.svg') + : 'assets/browseros.svg' + +interface BrowserOSPromptEditorProps { + provider: LLMProvider | undefined + onSave: (prompt: string) => Promise +} + +export function BrowserOSPromptEditor({ provider, onSave }: BrowserOSPromptEditorProps) { + const [promptValue, setPromptValue] = useState('') + const [isSaving, setIsSaving] = useState(false) + const [errorMessage, setErrorMessage] = useState(null) + const [isEditing, setIsEditing] = useState(true) + + const providerId = provider?.id ?? 'browseros' + const previousProviderIdRef = useRef(null) + const previousSystemPromptRef = useRef(undefined) + + // Reset local state whenever provider changes + useEffect(() => { + const nextValue = provider?.systemPrompt ?? '' + setPromptValue(nextValue) + setErrorMessage(null) + + const providerChanged = previousProviderIdRef.current !== providerId + const systemPromptChanged = previousSystemPromptRef.current !== provider?.systemPrompt + + if (providerChanged) { + // Allow editing immediately for brand new provider setups with no saved prompt + setIsEditing(nextValue.length === 0) + } else if (systemPromptChanged) { + // After saving, lock editing UNLESS the prompt is now empty (user clicked reset) + // Keep user in edit mode when prompt is empty so they can immediately add new content + setIsEditing(nextValue.length === 0) + } + + previousProviderIdRef.current = providerId + previousSystemPromptRef.current = provider?.systemPrompt + }, [providerId, provider?.systemPrompt]) + + const isDirty = useMemo(() => { + const current = provider?.systemPrompt ?? '' + return promptValue !== current + }, [provider?.systemPrompt, promptValue]) + + if (!provider) { + return null + } + + const handleSave = async () => { + if (!isDirty || isSaving || !isEditing) return + setIsSaving(true) + setErrorMessage(null) + try { + await onSave(promptValue) + setIsEditing(false) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to save prompt' + setErrorMessage(message) + } finally { + setIsSaving(false) + } + } + + const handleReset = async () => { + if (isSaving || !isEditing) return + setIsSaving(true) + setErrorMessage(null) + try { + // Save the empty prompt to storage so it persists across refreshes + await onSave('') + setPromptValue('') + // Keep user in edit mode after reset so they can add new content without clicking "Edit prompt" + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to reset prompt' + setErrorMessage(message) + // Revert to the previous value on error + setPromptValue(provider?.systemPrompt ?? '') + } finally { + setIsSaving(false) + } + } + + const handleEnterEditMode = () => { + setIsEditing(true) + setErrorMessage(null) + } + + return ( +
+
+
+
+

+ BrowserOS system prompt +

+

+ Add optional instructions that run before the built-in BrowserOS guidance. + These notes are prepended whenever BrowserOS agent mode is active. +

+
+
+ BrowserOS logo +
+
+ +
+