import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; import { API_BASE } from '@/shared/config'; const SETTINGS_API = `${API_BASE}/settings`; export const DEFAULT_SYSTEM_PROMPT = `You are a personal AI assistant running inside OpenSwarm.\n\n` + `## Tool Priority\n` + `When a dedicated MCP tool exists for a task, use it directly. Do not use the browser for things MCP tools can handle.\n` + `Priority order:\n` + `1. MCP tools first (Reddit, Google Workspace, etc.); fastest and most reliable\n` + `2. WebSearch / WebFetch for general web lookups without a dedicated MCP\n` + `3. BrowserAgent only when you need to visually interact with a website, fill forms, or do something no other tool can handle\n\n` + `## Tool Call Style\n` + `Default: do not narrate routine tool calls. Just call the tool.\n` + `Narrate only when it helps: multi-step work, complex problems, or when the user explicitly asks.\n` + `Keep narration brief. Use plain language.\n\n` + `## Interaction Style\n` + `Be direct and action-oriented. Do not ask clarifying questions unless genuinely ambiguous; ` + `make reasonable assumptions and act. If you need to ask, use the AskUserQuestion tool.\n` + `Do not over-explain what you are about to do. Just do it and show the results.`; export interface CustomProvider { name: string; base_url: string; api_key: string; models: Array<{ value: string; label: string; context_window?: number }>; } export interface SubscriptionUsage { requests_in_window: number; plan_limit: number; window_hours: number; /** unix ms */ window_ends_at: number; } export interface AppSettings { default_system_prompt: string | null; default_folder: string | null; default_model: string; default_mode: string; default_max_turns: number | null; default_thinking_level: 'off' | 'low' | 'medium' | 'high' | 'auto'; zoom_sensitivity: number; theme: 'light' | 'dark'; new_agent_shortcut: string; anthropic_api_key: string | null; openai_api_key?: string | null; google_api_key?: string | null; openrouter_api_key?: string | null; custom_providers?: CustomProvider[]; browser_homepage: string; auto_select_mode_on_new_agent: boolean; expand_new_chats_in_dashboard: boolean; auto_reveal_sub_agents: boolean; dev_mode: boolean; allow_experimental_updates: boolean; /** Managed subscription state; surfaces only when user has subscribed via cloud. */ connection_mode?: 'own_key' | 'openswarm-pro' | 'free-trial'; openswarm_bearer_token?: string | null; openswarm_proxy_url?: string | null; /** Zero-config free trial: server-owned, set by the cloud mint. remaining drives the onboarding "runs low" nudge. */ free_trial_token?: string | null; free_trial_remaining?: number | null; free_trial_runs_limit?: number | null; /** Epoch seconds when the rolling window refills; powers the "fresh runs in ~Xh" nudge. */ free_trial_resets_at?: number | null; openswarm_subscription_plan?: string | null; openswarm_subscription_expires?: string | null; openswarm_usage_cached?: SubscriptionUsage | null; /** Identity populated by /api/auth/signin-activate; Stripe checkout also fills these. */ user_id?: string | null; user_email?: string | null; signin_method?: 'google' | 'email' | 'stripe' | null; /** Anonymous device id (first-run generated); stitches anon to authed PostHog Persons. */ installation_id?: string | null; /** Onboarding v3 lifecycle: absent = never seen, 'done'/'skipped' once resolved. */ onboarding_v3?: string | null; /** User-picked accent hex from the onboarding theme pad; null = stock accent. */ accent_color?: string | null; personalized_greeting?: string | null; personalized_starters?: PersonalizedStarter[]; } export interface PersonalizedStarter { title: string; prompt: string; } export interface ActivateSubscriptionPayload { token: string; plan?: string | null; expires?: string | null; } export interface ActivateSigninPayload { token: string; signin_method: 'google' | 'email'; email?: string | null; } export interface BrowseResult { current: string; parent: string | null; directories: string[]; files: string[]; } interface SettingsState { data: AppSettings; loading: boolean; loaded: boolean; modalOpen: boolean; /** When non-null, Settings opens to this tab instead of 'general'. */ initialTab: string | null; /** In-flight form edits preserved across modal close/reopen; null = synced with `data`. */ draft: AppSettings | null; /** Tab the user was on when they closed the modal with unsaved edits. */ draftTab: string | null; /** Newest settings-write requestId. A stale GET that resolves after a newer * fetch/PUT is dropped, so a slow boot fetch can't clobber the free-trial arm. */ latestWriteId: string | null; /** False until the boot free-trial mint attempt settles; gates the no-model banner. */ freeTrialArmSettled: boolean; } /** Baseline for every required settings field. Spread under any backend payload so an older saved shape that predates a newer field (e.g. new_agent_shortcut) can't surface as undefined and crash a consumer. */ export const DEFAULT_SETTINGS: AppSettings = { default_system_prompt: DEFAULT_SYSTEM_PROMPT, default_folder: null, default_model: 'sonnet', default_mode: 'agent', default_max_turns: null, default_thinking_level: 'auto', zoom_sensitivity: 50, theme: 'light', new_agent_shortcut: 'Meta+l', anthropic_api_key: null, browser_homepage: 'https://duckduckgo.com', auto_select_mode_on_new_agent: false, expand_new_chats_in_dashboard: true, auto_reveal_sub_agents: true, dev_mode: false, allow_experimental_updates: false, }; const initialState: SettingsState = { data: DEFAULT_SETTINGS, loading: false, loaded: false, modalOpen: false, initialTab: null, draft: null, draftTab: null, latestWriteId: null, freeTrialArmSettled: false, }; export const fetchSettings = createAsyncThunk('settings/fetch', async () => { const res = await fetch(SETTINGS_API); return (await res.json()) as AppSettings; }); // Save ONLY the fields the user changed, merged server-side onto fresh state. Every renderer save uses this so a stale full object can never clobber a field the user didn't touch (e.g. one an agent just changed). export const updateSettingsPatch = createAsyncThunk( 'settings/patch', async (changes: Partial) => { const res = await fetch(SETTINGS_API, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(changes), }); const data = await res.json(); return data.settings as AppSettings; } ); export const resetSystemPrompt = createAsyncThunk( 'settings/resetSystemPrompt', async () => { const res = await fetch(`${SETTINGS_API}/reset-system-prompt`, { method: 'POST' }); const data = await res.json(); return data.settings as AppSettings; } ); export const dismissMcpSuggestion = createAsyncThunk( 'settings/dismissMcpSuggestion', async (ids: string[]) => { const res = await fetch(`${SETTINGS_API}/dismiss-mcp-suggestion`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids }), }); const data = await res.json(); return data.settings as AppSettings; } ); export const browseDirectories = createAsyncThunk( 'settings/browseDirectories', async (path: string) => { const res = await fetch(`${SETTINGS_API}/browse-directories?path=${encodeURIComponent(path)}`); if (!res.ok) throw new Error((await res.json()).detail); return (await res.json()) as BrowseResult; } ); /** POST /api/subscription/activate after catching openswarm://auth deep link; flips UI to Pro. */ export const activateSubscription = createAsyncThunk( 'settings/activateSubscription', async (payload: ActivateSubscriptionPayload, { dispatch }) => { const res = await fetch(`${API_BASE}/subscription/activate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!res.ok) throw new Error((await res.text()) || 'Activation failed'); await dispatch(fetchSettings()); return (await res.json()) as { ok: boolean; plan: string }; } ); /** POST /api/auth/signin-activate after catching Google OAuth/magic-link bearer; persists identity. */ export const activateSignin = createAsyncThunk( 'settings/activateSignin', async (payload: ActivateSigninPayload, { dispatch }) => { const res = await fetch(`${API_BASE}/auth/signin-activate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!res.ok) throw new Error((await res.text()) || 'Sign-in failed'); await dispatch(fetchSettings()); return (await res.json()) as { ok: boolean; user_id: string; email: string; plan: string; signin_method: 'google' | 'email'; }; }, ); /** POST /api/auth/signout; revokes cloud bearer, clears local identity. */ export const signOut = createAsyncThunk( 'settings/signOut', async (_: void, { dispatch }) => { const res = await fetch(`${API_BASE}/auth/signout`, { method: 'POST' }); if (!res.ok) throw new Error('Sign-out failed'); await dispatch(fetchSettings()); return true; }, ); /** POST /api/subscription/disconnect; clears bearer, reverts to own_key. Doesn't cancel Stripe. */ export const disconnectSubscription = createAsyncThunk( 'settings/disconnectSubscription', async (_: void, { dispatch }) => { const res = await fetch(`${API_BASE}/subscription/disconnect`, { method: 'POST' }); if (!res.ok) throw new Error('Disconnect failed'); await dispatch(fetchSettings()); return true; } ); const settingsSlice = createSlice({ name: 'settings', initialState, reducers: { openSettingsModal(state, action: PayloadAction) { state.modalOpen = true; state.initialTab = action.payload ?? null; }, closeSettingsModal(state) { state.modalOpen = false; state.initialTab = null; }, /** Persist in-flight form edits + tab so they survive modal close; clearDraft drops the marker. */ setDraft(state, action: PayloadAction<{ form: AppSettings; tab: string }>) { state.draft = action.payload.form; state.draftTab = action.payload.tab; }, clearDraft(state) { state.draft = null; state.draftTab = null; }, /** Boot sets this once the free-trial mint attempt returns (armed or not). Until then * we hold the "No AI model connected" banner so a new user never sees it flash. */ markFreeTrialArmSettled(state) { state.freeTrialArmSettled = true; }, }, extraReducers: (builder) => { builder .addCase(fetchSettings.pending, (state, action) => { state.loading = true; state.latestWriteId = action.meta.requestId; }) .addCase(fetchSettings.fulfilled, (state, action) => { state.loading = false; state.loaded = true; // Drop a stale response: on boot three fetches race (initial, sub-sync, free-trial mint); if the pre-mint one resolves last it would wipe the armed trial. Newest wins. if (state.latestWriteId && action.meta.requestId !== state.latestWriteId) return; // Fill any field an older backend shape omitted so no consumer reads undefined; the payload still wins for everything it does send. const merged = { ...DEFAULT_SETTINGS, ...action.payload }; // Skip ref-assignment when byte-identical; keeps background refetch polls from re-firing every effect. const next = JSON.stringify(merged); const prev = JSON.stringify(state.data); if (next !== prev) { state.data = merged; } }) .addCase(fetchSettings.rejected, (state) => { state.loading = false; state.loaded = true; }) .addCase(updateSettingsPatch.fulfilled, (state, action) => { // A user save is authoritative; claim newest so an in-flight GET can't overwrite it, and consume the draft so reopening shows the saved state. state.latestWriteId = action.meta.requestId; state.data = { ...DEFAULT_SETTINGS, ...action.payload }; state.draft = null; state.draftTab = null; }) .addCase(resetSystemPrompt.fulfilled, (state, action) => { state.latestWriteId = action.meta.requestId; state.data = { ...DEFAULT_SETTINGS, ...action.payload }; state.draft = null; state.draftTab = null; }) .addCase(dismissMcpSuggestion.fulfilled, (state, action) => { state.latestWriteId = action.meta.requestId; state.data = { ...DEFAULT_SETTINGS, ...action.payload }; }); }, }); export const { openSettingsModal, closeSettingsModal, setDraft, clearDraft, markFreeTrialArmSettled } = settingsSlice.actions; export default settingsSlice.reducer;