From 8bacdc9f2477c14c236bed2950517f1ced0e9ed1 Mon Sep 17 00:00:00 2001 From: Valerii Kot Date: Mon, 8 Dec 2025 15:38:12 +0100 Subject: [PATCH 01/33] Flutter PR Code Review (#6011) --- .../data/recipes/flutter-pr-code-review.yaml | 229 ++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 documentation/src/pages/recipes/data/recipes/flutter-pr-code-review.yaml diff --git a/documentation/src/pages/recipes/data/recipes/flutter-pr-code-review.yaml b/documentation/src/pages/recipes/data/recipes/flutter-pr-code-review.yaml new file mode 100644 index 0000000000..768ada4670 --- /dev/null +++ b/documentation/src/pages/recipes/data/recipes/flutter-pr-code-review.yaml @@ -0,0 +1,229 @@ +version: "1.0.0" +title: "Flutter PR Code Review" +description: "Automated expert-level Flutter/Dart code review powered by official Flutter AI rules and real-time Context7 documentation. Analyzes PRs for null safety, state management (BLoC, Riverpod, Provider), architecture patterns, performance optimizations, accessibility compliance, and security vulnerabilities. Provides categorized feedback (Critical/Warning/Suggestion) with file:line references and an actionable summary with approval recommendations. Requires Context7 MCP extension with CONTEXT7_API_KEY environment variable." +author: + contact: "valerii@rimthan.com" + +instructions: | + You are an expert Flutter/Dart code reviewer created by Valerii from Rimthan. + + YOUR IDENTITY: + - Senior Flutter developer with 10+ years of mobile development experience + - Expert in Dart, Flutter framework, state management, and mobile architecture + - Specializes in banking and fintech applications + - Uses Context7 MCP to access latest documentation for ALL libraries + + CRITICAL RULES - READ-ONLY MODE: + - DO NOT create, modify, or delete any files + - DO NOT run any git commands that modify the repository + - ONLY read and analyze the code + - ONLY provide review feedback as text output + + ═══════════════════════════════════════════════════════════════ + STEP 1 - FETCH DOCUMENTATION (ALWAYS DO THIS FIRST!) + ═══════════════════════════════════════════════════════════════ + + 1. Download and read official Flutter AI rules: + curl -sL "https://raw.githubusercontent.com/flutter/flutter/refs/heads/main/docs/rules/rules.md" + + 2. Use Context7 MCP to get fresh documentation: + - First call: resolve-library-id for "flutter" + - Then call: get-library-docs for /flutter/flutter + - First call: resolve-library-id for "dart" + - Then call: get-library-docs for /dart-lang/sdk + + 3. Analyze pubspec.yaml to find all dependencies: + - Read pubspec.yaml + - For each major dependency, use Context7 to fetch docs + + Common Flutter packages Context7 mappings: + - bloc, flutter_bloc → /felangel/bloc + - riverpod, flutter_riverpod → /rrousselgit/riverpod + - provider → /rrousselgit/provider + - dio → /cfug/dio + - get_it → /fluttercommunity/get_it + - freezed → /rrousselgit/freezed + - go_router → /flutter/packages + - hive → /isar/hive + - auto_route → /Milad-Akarie/auto_route_library + - injectable → /Milad-Akarie/injectable + - dartz → /spebbe/dartz + - equatable → /felangel/equatable + - json_serializable → /google/json_serializable + + ═══════════════════════════════════════════════════════════════ + STEP 2 - ANALYZE CHANGES + ═══════════════════════════════════════════════════════════════ + + Run: git diff origin/main...HEAD + + Or if reviewing specific files, read each changed file. + + ═══════════════════════════════════════════════════════════════ + STEP 3 - REVIEW CODE (based on Flutter AI rules + Context7 docs) + ═══════════════════════════════════════════════════════════════ + + DART BEST PRACTICES (from official Flutter rules): + - Follow Effective Dart guidelines (https://dart.dev/effective-dart) + - Proper null safety - avoid ! unless value is guaranteed non-null + - Use async/await correctly with robust error handling + - Pattern matching and records where they simplify code + - Exhaustive switch statements (no break needed) + - Arrow syntax for simple one-line functions + - Use try-catch with appropriate exception types + - PascalCase for classes, camelCase for members, snake_case for files + - Line length 80 characters or fewer + - Functions under 20 lines with single purpose + + FLUTTER BEST PRACTICES (from official Flutter rules): + - Widgets (especially StatelessWidget) are immutable + - Composition over inheritance - compose smaller widgets + - Use const constructors whenever possible to reduce rebuilds + - Break down large build() methods into smaller private Widget classes + - Use small, private Widget classes instead of helper methods returning Widget + - Use ListView.builder or SliverList for long lists (lazy loading) + - Use compute() for expensive calculations in separate isolate + - Avoid expensive operations (network, complex computations) in build() methods + - Use logging package instead of print + + STATE MANAGEMENT (verify against Context7 docs): + - Prefer Flutter built-in: ValueNotifier, ChangeNotifier, Streams + - If using BLoC/Cubit - verify proper event/state separation + - If using Riverpod - verify proper provider usage and disposal + - If using Provider - verify ChangeNotifier usage + - Separate ephemeral state from app state + - Proper dispose/close of controllers and streams + - Use MVVM pattern for robust solutions + + ARCHITECTURE (from official Flutter rules): + - Separation of concerns (MVC/MVVM) + - Logical layers: Presentation, Domain, Data, Core + - Feature-based organization for larger projects + - Repository pattern for data abstraction + - Manual constructor dependency injection + + CODE QUALITY: + - Meaningful, consistent naming (no abbreviations) + - Documentation comments (///) for all public APIs + - Clear comments for complex/non-obvious code + - Don't repeat information obvious from code context + - API documentation should be user-centric + + UI/THEMING (from official Flutter rules): + - Centralized ThemeData object + - Light and dark theme support (ThemeMode.light, .dark, .system) + - Use ColorScheme.fromSeed() for harmonious color palettes + - Responsive layouts with LayoutBuilder or MediaQuery + - Use Theme.of(context).textTheme for text styles + - Custom fonts via google_fonts package + - Network images: always include loadingBuilder and errorBuilder + + ACCESSIBILITY (from official Flutter rules): + - Color contrast ratio at least 4.5:1 for text + - Test with increased system font size + - Use Semantics widget for clear labels + - Test with TalkBack (Android) and VoiceOver (iOS) + + LIBRARY-SPECIFIC CHECKS: + - Verify correct API usage based on Context7 documentation + - Check for deprecated methods or patterns + - Ensure best practices for each library are followed + - Check version compatibility + + SECURITY: + - Sensitive data handling + - API key exposure check (no hardcoded keys) + - Secure storage usage (flutter_secure_storage) + - Input validation + - HTTPS for network requests + + TESTING (from official Flutter rules): + - Unit tests for domain logic, data layer, state management + - Widget tests for UI components + - Integration tests for end-to-end flows + - Arrange-Act-Assert (Given-When-Then) pattern + - Prefer fakes/stubs over mocks + - Use package:checks for readable assertions + + ═══════════════════════════════════════════════════════════════ + OUTPUT FORMAT + ═══════════════════════════════════════════════════════════════ + + Provide specific feedback with file:line references. + + Categorize issues as: + - 🔴 CRITICAL: Must fix before merge (bugs, security issues, crashes) + - 🟡 WARNING: Should fix (performance, bad practices, violates Flutter rules) + - 🟢 SUGGESTION: Nice to have (style, minor improvements) + - ✅ GOOD: Positive aspects worth noting + + ═══════════════════════════════════════════════════════════════ + SUMMARY (at the end) + ═══════════════════════════════════════════════════════════════ + + - Overall code quality score (1-10) + - Libraries/frameworks detected and reviewed (with Context7) + - Top 3 issues to address + - Positive aspects of the code + - Recommendation: APPROVE / REQUEST_CHANGES / NEEDS_DISCUSSION + +prompt: | + Review the code in this Flutter repository. + + IMPORTANT: Use shell commands (via developer extension) to read files. Do NOT use filesystem extension. + + STEPS: + + 1. FETCH FLUTTER RULES (run shell command): + ```bash + curl -sL "https://raw.githubusercontent.com/flutter/flutter/refs/heads/main/docs/rules/rules.md" + ``` + + 2. FETCH CONTEXT7 DOCS: + Use Context7 MCP tools to get documentation for Flutter and Dart: + - Call resolve-library-id with query "flutter" + - Call get-library-docs for the Flutter library + - Call resolve-library-id with query "dart" + - Call get-library-docs for Dart + + 3. ANALYZE DEPENDENCIES (run shell commands): + ```bash + cat pubspec.yaml + ``` + For each major dependency (bloc, riverpod, dio, etc.), fetch Context7 docs. + + 4. GET DIFF (run shell command): + ```bash + git diff origin/main...HEAD + ``` + + 5. REVIEW each changed file against: + - Official Flutter AI rules + - Context7 documentation for each library used + + Remember: READ-ONLY mode - do not modify any files. + +extensions: + - type: builtin + name: developer + timeout: 300 + - type: stdio + name: context7 + cmd: npx + args: + - "-y" + - "@upstash/context7-mcp" + timeout: 300 + description: "Context7 MCP for up-to-date Flutter/Dart and library documentation" + env_keys: + - CONTEXT7_API_KEY + +activities: + - "Fetch Flutter AI rules and Context7 docs" + - "Analyze dependencies from pubspec.yaml" + - "Review Flutter widget patterns" + - "Check Dart null safety" + - "Analyze state management" + - "Verify library API usage" + - "Identify performance issues" + - "Check security concerns" From 13d1f3077e6a29f7655fa158e3c68e46ec8dc6cc Mon Sep 17 00:00:00 2001 From: dianed-square <73617011+dianed-square@users.noreply.github.com> Date: Mon, 8 Dec 2025 11:30:08 -0800 Subject: [PATCH 02/33] docs: additional mcp sampling resources (#6020) --- documentation/docs/guides/mcp-sampling.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/documentation/docs/guides/mcp-sampling.md b/documentation/docs/guides/mcp-sampling.md index 3eb7f0fbb7..ec7dd15d54 100644 --- a/documentation/docs/guides/mcp-sampling.md +++ b/documentation/docs/guides/mcp-sampling.md @@ -14,6 +14,8 @@ This feature is automatically enabled in goose, no configuration required! Any M - goose users can get more targeted responses tailored to the extension's specific capabilities - developers can add sampling support to their MCP servers to provide enhanced capabilities in goose +Try out the [Council of Mine](/docs/mcp/council-of-mine-mcp) extension to see MCP sampling in action! + :::info [MCP Sampling](https://modelcontextprotocol.io/specification/draft/client/sampling) is a feature in the Model Context Protocol. ::: @@ -78,3 +80,22 @@ MCP Sampling enables powerful capabilities like: ## For Extension Developers Want to add MCP Sampling to your own extensions? See our [Building Custom Extensions](/docs/tutorials/custom-extensions) tutorial to learn more about how MCP servers can leverage goose's AI capabilities. + +## Additional Resources + +import ContentCardCarousel from '@site/src/components/ContentCardCarousel'; +import mcpSampling from '@site/blog/2025-12-04-mcp-sampling/mcp-sampling.png'; + + From 92e75ac4600c8cbb9123061c79a4844bfe62d356 Mon Sep 17 00:00:00 2001 From: Zane <75694352+zanesq@users.noreply.github.com> Date: Mon, 8 Dec 2025 13:27:53 -0800 Subject: [PATCH 03/33] Show modal selector after configuring a provider (#6005) --- ui/desktop/src/components/ProviderGuard.tsx | 60 +++++---- .../models/subcomponents/SwitchModelModal.tsx | 118 +++++++++++++----- .../settings/providers/ProviderGrid.tsx | 69 ++++++++-- .../providers/ProviderSettingsPage.tsx | 54 ++------ .../modal/ProviderConfiguationModal.tsx | 8 +- 5 files changed, 201 insertions(+), 108 deletions(-) diff --git a/ui/desktop/src/components/ProviderGuard.tsx b/ui/desktop/src/components/ProviderGuard.tsx index 76e789d6e1..227be70e49 100644 --- a/ui/desktop/src/components/ProviderGuard.tsx +++ b/ui/desktop/src/components/ProviderGuard.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { useConfig } from './ConfigContext'; import { SetupModal } from './SetupModal'; @@ -8,6 +8,8 @@ import WelcomeGooseLogo from './WelcomeGooseLogo'; import { toastService } from '../toasts'; import { OllamaSetup } from './OllamaSetup'; import ApiKeyTester from './ApiKeyTester'; +import { SwitchModelModal } from './settings/models/subcomponents/SwitchModelModal'; +import { createNavigationHandler } from '../utils/navigationUtils'; import { Goose, OpenRouter, Tetrate } from './icons'; @@ -24,6 +26,10 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG const [showFirstTimeSetup, setShowFirstTimeSetup] = useState(false); const [showOllamaSetup, setShowOllamaSetup] = useState(false); const [userInActiveSetup, setUserInActiveSetup] = useState(false); + const [showSwitchModelModal, setShowSwitchModelModal] = useState(false); + const [switchModelProvider, setSwitchModelProvider] = useState(null); + + const setView = useMemo(() => createNavigationHandler(navigate), [navigate]); const [openRouterSetupState, setOpenRouterSetupState] = useState<{ show: boolean; @@ -45,18 +51,8 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG try { const result = await startTetrateSetup(); if (result.success) { - setTetrateSetupState({ - show: true, - title: 'Setup Complete!', - message: result.message, - showRetry: false, - autoClose: 3000, - }); - setTimeout(() => { - setShowFirstTimeSetup(false); - setHasProvider(true); - navigate('/', { replace: true }); - }, 3000); + setSwitchModelProvider('tetrate'); + setShowSwitchModelModal(true); } else { setTetrateSetupState({ show: true, @@ -76,34 +72,33 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG } }; - const handleApiKeySuccess = async (provider: string, model: string, apiKey: string) => { + const handleApiKeySuccess = async (provider: string, _model: string, apiKey: string) => { const keyName = `${provider.toUpperCase()}_API_KEY`; await upsert(keyName, apiKey, true); await upsert('GOOSE_PROVIDER', provider, false); - await upsert('GOOSE_MODEL', model, false); + setSwitchModelProvider(provider); + setShowSwitchModelModal(true); + }; + + const handleModelSelected = () => { + setShowSwitchModelModal(false); setUserInActiveSetup(false); setShowFirstTimeSetup(false); setHasProvider(true); navigate('/', { replace: true }); }; + const handleSwitchModelClose = () => { + setShowSwitchModelModal(false); + }; + const handleOpenRouterSetup = async () => { try { const result = await startOpenRouterSetup(); if (result.success) { - setOpenRouterSetupState({ - show: true, - title: 'Setup Complete!', - message: result.message, - showRetry: false, - autoClose: 3000, - }); - setTimeout(() => { - setShowFirstTimeSetup(false); - setHasProvider(true); - navigate('/', { replace: true }); - }, 3000); + setSwitchModelProvider('openrouter'); + setShowSwitchModelModal(true); } else { setOpenRouterSetupState({ show: true, @@ -337,6 +332,17 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG autoClose={tetrateSetupState.autoClose} /> )} + + {showSwitchModelModal && ( + + )} ); } diff --git a/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx b/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx index abcc270033..73bb0c8bcb 100644 --- a/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx +++ b/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useCallback } from 'react'; -import { ArrowLeftRight, ExternalLink } from 'lucide-react'; +import { Bot, ExternalLink } from 'lucide-react'; import { Dialog, @@ -20,18 +20,66 @@ import Model, { getProviderMetadata, fetchModelsForProviders } from '../modelInt import { getPredefinedModelsFromEnv, shouldShowPredefinedModels } from '../predefinedModelsUtils'; import { ProviderType } from '../../../../api'; +const PREFERRED_MODEL_PATTERNS = [ + /claude-sonnet-4/i, + /claude-4/i, + /gpt-4o(?!-mini)/i, + /claude-3-5-sonnet/i, + /claude-3\.5-sonnet/i, + /gpt-4-turbo/i, + /gpt-4(?!-|o)/i, + /claude-3-opus/i, + /claude-3-sonnet/i, + /gemini-pro/i, + /llama-3/i, + /gpt-4o-mini/i, + /claude-3-haiku/i, + /gemini/i, +]; + +function findPreferredModel( + models: { value: string; label: string; provider: string }[] +): string | null { + if (models.length === 0) return null; + + const validModels = models.filter( + (m) => m.value !== 'custom' && m.value !== '__loading__' && !m.value.startsWith('__') + ); + + if (validModels.length === 0) return null; + + for (const pattern of PREFERRED_MODEL_PATTERNS) { + const match = validModels.find((m) => pattern.test(m.value)); + if (match) { + return match.value; + } + } + + return validModels[0].value; +} + type SwitchModelModalProps = { sessionId: string | null; onClose: () => void; setView: (view: View) => void; + onModelSelected?: () => void; + initialProvider?: string | null; + titleOverride?: string; }; -export const SwitchModelModal = ({ sessionId, onClose, setView }: SwitchModelModalProps) => { +export const SwitchModelModal = ({ + sessionId, + onClose, + setView, + onModelSelected, + initialProvider, + titleOverride, +}: SwitchModelModalProps) => { const { getProviders, getProviderModels, read } = useConfig(); const { changeModel } = useModelAndProvider(); const [providerOptions, setProviderOptions] = useState<{ value: string; label: string }[]>([]); type ModelOption = { value: string; label: string; provider: string; isDisabled?: boolean }; const [modelOptions, setModelOptions] = useState<{ options: ModelOption[] }[]>([]); - const [provider, setProvider] = useState(null); + const [provider, setProvider] = useState(initialProvider || null); const [model, setModel] = useState(''); const [isCustomModel, setIsCustomModel] = useState(false); const [validationErrors, setValidationErrors] = useState({ @@ -95,6 +143,9 @@ export const SwitchModelModal = ({ sessionId, onClose, setView }: SwitchModelMod } await changeModel(sessionId, modelObj); + if (onModelSelected) { + onModelSelected(); + } onClose(); } }; @@ -209,11 +260,25 @@ export const SwitchModelModal = ({ sessionId, onClose, setView }: SwitchModelMod })(); }, [getProviders, getProviderModels, usePredefinedModels, read]); - // Filter model options based on selected provider const filteredModelOptions = provider ? modelOptions.filter((group) => group.options[0]?.provider === provider) : []; + useEffect(() => { + if (!provider || loadingModels || model || isCustomModel) return; + + const providerModels = modelOptions + .filter((group) => group.options[0]?.provider === provider) + .flatMap((group) => group.options); + + if (providerModels.length > 0) { + const preferredModel = findPreferredModel(providerModels); + if (preferredModel) { + setModel(preferredModel); + } + } + }, [provider, modelOptions, loadingModels, model, isCustomModel]); + // Handle model selection change const handleModelChange = (newValue: unknown) => { const selectedOption = newValue as { value: string; label: string; provider: string } | null; @@ -277,30 +342,16 @@ export const SwitchModelModal = ({ sessionId, onClose, setView }: SwitchModelMod - - Switch models + + {titleOverride || 'Switch models'} - Configure your AI model providers by adding their API keys. Your keys are stored - securely and encrypted locally. + Select a provider and model to use for your conversations.
- - {usePredefinedModels ? ( - /* Predefined Models Section */
@@ -448,13 +499,24 @@ export const SwitchModelModal = ({ sessionId, onClose, setView }: SwitchModelMod )}
- - - + + + + Quick start guide + +
+ + +
diff --git a/ui/desktop/src/components/settings/providers/ProviderGrid.tsx b/ui/desktop/src/components/settings/providers/ProviderGrid.tsx index f970e93356..55683122ec 100644 --- a/ui/desktop/src/components/settings/providers/ProviderGrid.tsx +++ b/ui/desktop/src/components/settings/providers/ProviderGrid.tsx @@ -10,6 +10,8 @@ import { import { Plus } from 'lucide-react'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../ui/dialog'; import CustomProviderForm from './modal/subcomponents/forms/CustomProviderForm'; +import { SwitchModelModal } from '../models/subcomponents/SwitchModelModal'; +import type { View } from '../../../utils/navigationUtils'; const GridLayout = memo(function GridLayout({ children }: { children: React.ReactNode }) { return ( @@ -50,21 +52,30 @@ function ProviderCards({ providers, isOnboarding, refreshProviders, - onProviderLaunch, + setView, + onModelSelected, }: { providers: ProviderDetails[]; isOnboarding: boolean; refreshProviders?: () => void; - onProviderLaunch: (provider: ProviderDetails) => void; + setView?: (view: View) => void; + onModelSelected?: () => void; }) { const [configuringProvider, setConfiguringProvider] = useState(null); const [showCustomProviderModal, setShowCustomProviderModal] = useState(false); + const [showSwitchModelModal, setShowSwitchModelModal] = useState(false); + const [switchModelProvider, setSwitchModelProvider] = useState(null); const [editingProvider, setEditingProvider] = useState<{ id: string; config: DeclarativeProviderConfig; isEditable: boolean; } | null>(null); + const handleProviderLaunchWithModelSelection = useCallback((provider: ProviderDetails) => { + setSwitchModelProvider(provider.name); + setShowSwitchModelModal(true); + }, []); + const openModal = useCallback( (provider: ProviderDetails) => setConfiguringProvider(provider), [] @@ -101,11 +112,14 @@ function ProviderCards({ body: data, throwOnError: true, }); + const providerId = editingProvider.id; setShowCustomProviderModal(false); setEditingProvider(null); if (refreshProviders) { refreshProviders(); } + setSwitchModelProvider(providerId); + setShowSwitchModelModal(true); }, [editingProvider, refreshProviders] ); @@ -122,6 +136,32 @@ function ProviderCards({ } }, [refreshProviders]); + const onProviderConfigured = useCallback( + (provider: ProviderDetails) => { + setConfiguringProvider(null); + if (refreshProviders) { + refreshProviders(); + } + setSwitchModelProvider(provider.name); + setShowSwitchModelModal(true); + }, + [refreshProviders] + ); + + const onCloseSwitchModelModal = useCallback(() => { + setShowSwitchModelModal(false); + }, []); + + const handleSetView = useCallback( + (view: View) => { + setShowSwitchModelModal(false); + if (setView) { + setView(view); + } + }, + [setView] + ); + const handleCreateCustomProvider = useCallback( async (data: UpdateCustomProviderRequest) => { const { createCustomProvider } = await import('../../../api'); @@ -130,6 +170,7 @@ function ProviderCards({ if (refreshProviders) { refreshProviders(); } + setShowSwitchModelModal(true); }, [refreshProviders] ); @@ -144,7 +185,7 @@ function ProviderCards({ key={provider.name} provider={provider} onConfigure={() => configureProviderViaModal(provider)} - onLaunch={() => onProviderLaunch(provider)} + onLaunch={() => handleProviderLaunchWithModelSelection(provider)} isOnboarding={isOnboarding} /> )); @@ -154,7 +195,7 @@ function ProviderCards({ ); return cards; - }, [providers, isOnboarding, configureProviderViaModal, onProviderLaunch]); + }, [providers, isOnboarding, configureProviderViaModal, handleProviderLaunchWithModelSelection]); const initialData = editingProvider && { engine: editingProvider.config.engine.toLowerCase() + '_compatible', @@ -187,6 +228,17 @@ function ProviderCards({ + )} + {showSwitchModelModal && ( + )} @@ -197,12 +249,14 @@ export default function ProviderGrid({ providers, isOnboarding, refreshProviders, - onProviderLaunch, + setView, + onModelSelected, }: { providers: ProviderDetails[]; isOnboarding: boolean; refreshProviders?: () => void; - onProviderLaunch?: (provider: ProviderDetails) => void; + setView?: (view: View) => void; + onModelSelected?: () => void; }) { return ( @@ -210,7 +264,8 @@ export default function ProviderGrid({ providers={providers} isOnboarding={isOnboarding} refreshProviders={refreshProviders} - onProviderLaunch={onProviderLaunch || (() => {})} + setView={setView} + onModelSelected={onModelSelected} /> ); diff --git a/ui/desktop/src/components/settings/providers/ProviderSettingsPage.tsx b/ui/desktop/src/components/settings/providers/ProviderSettingsPage.tsx index 2110b6f60a..1c745a3fd3 100644 --- a/ui/desktop/src/components/settings/providers/ProviderSettingsPage.tsx +++ b/ui/desktop/src/components/settings/providers/ProviderSettingsPage.tsx @@ -1,10 +1,11 @@ -import { useEffect, useState, useCallback, useRef } from 'react'; +import { useEffect, useState, useCallback, useRef, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; import { ScrollArea } from '../../ui/scroll-area'; import BackButton from '../../ui/BackButton'; import ProviderGrid from './ProviderGrid'; import { useConfig } from '../../ConfigContext'; -import { ProviderDetails, setConfigProvider } from '../../../api'; -import { toastService } from '../../../toasts'; +import { ProviderDetails } from '../../../api'; +import { createNavigationHandler } from '../../../utils/navigationUtils'; interface ProviderSettingsProps { onClose: () => void; @@ -18,10 +19,13 @@ export default function ProviderSettings({ onProviderLaunched, }: ProviderSettingsProps) { const { getProviders } = useConfig(); + const navigate = useNavigate(); const [loading, setLoading] = useState(true); const [providers, setProviders] = useState([]); const initialLoadDone = useRef(false); + const setView = useMemo(() => createNavigationHandler(navigate), [navigate]); + // Create a function to load providers that can be called multiple times const loadProviders = useCallback(async () => { setLoading(true); @@ -54,47 +58,6 @@ export default function ProviderSettings({ } }, [getProviders]); - // Handler for when a provider is launched if this component is used as part of onboarding page - const handleProviderLaunch = useCallback( - async (provider: ProviderDetails) => { - const provider_name = provider.name; - const model = provider.metadata.default_model; - - try { - await setConfigProvider({ - body: { - provider: provider_name, - model, - }, - throwOnError: true, - }); - - toastService.configure({ silent: false }); - toastService.success({ - title: 'Success!', - msg: `Started goose with ${model} by ${provider.metadata.display_name}. You can change the model via the dropdown.`, - }); - - if (onProviderLaunched) { - onProviderLaunched(); - } else { - onClose(); - } - } catch (error) { - console.error(`Failed to initialize with provider ${provider_name}:`, error); - - // Show error toast - toastService.configure({ silent: false }); - toastService.error({ - title: 'Initialization Failed', - msg: `Failed to initialize with ${provider.metadata.display_name}: ${error instanceof Error ? error.message : String(error)}`, - traceback: error instanceof Error ? error.stack || '' : '', - }); - } - }, - [onClose, onProviderLaunched] - ); - return (
@@ -127,8 +90,9 @@ export default function ProviderSettings({ )}
diff --git a/ui/desktop/src/components/settings/providers/modal/ProviderConfiguationModal.tsx b/ui/desktop/src/components/settings/providers/modal/ProviderConfiguationModal.tsx index 1858589eb8..a0b76483ca 100644 --- a/ui/desktop/src/components/settings/providers/modal/ProviderConfiguationModal.tsx +++ b/ui/desktop/src/components/settings/providers/modal/ProviderConfiguationModal.tsx @@ -23,11 +23,13 @@ import { Button } from '../../../../components/ui/button'; interface ProviderConfigurationModalProps { provider: ProviderDetails; onClose: () => void; + onConfigured?: (provider: ProviderDetails) => void; } export default function ProviderConfigurationModal({ provider, onClose, + onConfigured, }: ProviderConfigurationModalProps) { const [validationErrors, setValidationErrors] = useState>({}); const { upsert, remove } = useConfig(); @@ -83,7 +85,11 @@ export default function ProviderConfigurationModal({ try { await providerConfigSubmitHandler(upsert, provider, toSubmit); - onClose(); + if (onConfigured) { + onConfigured(provider); + } else { + onClose(); + } } catch (error) { setError(`${error}`); } From 07007180e9a5d0f3a8e386bdaa4ede79a2d83171 Mon Sep 17 00:00:00 2001 From: dianed-square <73617011+dianed-square@users.noreply.github.com> Date: Mon, 8 Dec 2025 13:50:51 -0800 Subject: [PATCH 04/33] docs: provider and model config (#6008) --- .../docs/getting-started/installation.md | 2 +- .../docs/getting-started/providers.md | 154 +++++++++++------- .../docs/guides/environment-variables.md | 4 +- .../handling-llm-rate-limits-with-goose.md | 2 +- 4 files changed, 95 insertions(+), 67 deletions(-) diff --git a/documentation/docs/getting-started/installation.md b/documentation/docs/getting-started/installation.md index 4e0b2ce495..d6a71c7745 100644 --- a/documentation/docs/getting-started/installation.md +++ b/documentation/docs/getting-started/installation.md @@ -320,7 +320,7 @@ You can change your LLM provider and/or model or update your API key at any time 1. Click the button in the top-left to open the sidebar. 2. Click the `Settings` button on the sidebar. 3. Click the `Models` tab. - 4. Choose to update your provider, switch models, or click `Reset Provider and Model` to clear your settings and return to the welcome screen. See details about these [configuration options](/docs/getting-started/providers#configure-provider). + 4. Choose to update your provider, switch models, or click `Reset Provider and Model` to clear your settings and return to the welcome screen. See details about these [configuration options](/docs/getting-started/providers#configure-provider-and-model). 1. Run the following command: diff --git a/documentation/docs/getting-started/providers.md b/documentation/docs/getting-started/providers.md index 4e09519bae..1356d0f77a 100644 --- a/documentation/docs/getting-started/providers.md +++ b/documentation/docs/getting-started/providers.md @@ -56,10 +56,9 @@ goose also supports special "pass-through" providers that work with existing CLI CLI providers are cost-effective alternatives that use your existing subscriptions. They work differently from API providers as they execute CLI commands and integrate with the tools' native capabilities. See the [CLI Providers guide](/docs/guides/cli-providers) for detailed setup instructions. ::: - -## Configure Provider +## Configure Provider and Model -To configure your chosen provider or see available options, visit the `Models` tab in goose Desktop or run `goose configure` in the CLI. +To configure your chosen provider, see available options, or select a model, visit the `Models` tab in goose Desktop or run `goose configure` in the CLI. @@ -128,72 +127,101 @@ To configure your chosen provider or see available options, visit the `Models` t 1. In your terminal, run the following command: - ```sh - goose configure - ``` + ```sh + goose configure + ``` - 2. Select `Configure Providers` from the menu and press Enter. + 2. Select `Configure Providers` from the menu and press `Enter`. - ``` - ┌ goose-configure - │ - ◆ What would you like to configure? - │ ● Configure Providers (Change provider or update credentials) - │ ○ Add Extension - │ ○ Toggle Extensions - │ ○ Remove Extension - │ ○ goose Settings - └ - ``` - 3. Choose a model provider and press Enter. + ``` + ┌ goose-configure + │ + ◆ What would you like to configure? + // highlight-start + │ ● Configure Providers (Change provider or update credentials) + // highlight-end + │ ○ Custom Providers + │ ○ Add Extension + │ ○ Toggle Extensions + │ ○ Remove Extension + │ ○ goose Settings + └ + ``` + 3. Choose a model provider and press `Enter`. Use the arrow keys (↑/↓) to move through the options. - ``` - ┌ goose-configure - │ - ◇ What would you like to configure? - │ Configure Providers - │ - ◆ Which model provider should we use? - │ ● Anthropic (Claude and other models from Anthropic) - │ ○ Azure OpenAI - │ ○ Amazon Bedrock - │ ○ Claude Code - │ ○ Databricks - │ ○ ... - └ - ``` - 4. Enter your API key (and any other configuration details) when prompted. + ``` + ┌ goose-configure + │ + ◇ What would you like to configure? + │ Configure Providers + │ + ◆ Which model provider should we use? + │ ○ Amazon Bedrock + │ ○ Amazon SageMaker TGI + // highlight-start + │ ● Anthropic (Claude and other models from Anthropic) + // highlight-end + │ ○ Azure OpenAI + │ ○ Claude Code CLI + │ ○ ... + └ + ``` + 4. Enter your API key (and any other configuration details) when prompted. - ``` - ┌ goose-configure - │ - ◇ What would you like to configure? - │ Configure Providers - │ - ◇ Which model provider should we use? - │ Anthropic - │ - ◆ Provider Anthropic requires ANTHROPIC_API_KEY, please enter a value - │ ▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪ - └ - ``` - 5. Enter your desired `ANTHROPIC_HOST` or you can use the default one by hitting the `Enter` key. + ``` + ┌ goose-configure + │ + ◇ What would you like to configure? + │ Configure Providers + │ + ◇ Which model provider should we use? + │ Anthropic + │ + ◆ Provider Anthropic requires ANTHROPIC_API_KEY, please enter a value + // highlight-start + │ ▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪ + // highlight-end + └ + ``` + + If you're just changing models, skip any prompts to update the provider configuration. - ``` - ◇ Enter new value for ANTHROPIC_HOST - │ https://api.anthropic.com (default) - ``` - 6. Enter the model you want to use or you can use the default one by hitting the `Enter` key. - ``` - │ - ◇ Model fetch complete - │ - ◇ Enter a model from that provider: - │ claude-sonnet-4-0 (default) - │ - ◓ Checking your configuration... - └ Configuration saved successfully + 5. Enter your desired `ANTHROPIC_HOST` or press `Enter` to use the default. + + ``` + ◆ Provider Anthropic requires ANTHROPIC_HOST, please enter a value + // highlight-start + │ https://api.anthropic.com (default) + // highlight-end + ``` + 6. Choose the model you want to use. Depending on the provider, you can: + - Select the model from a list + - Search for the model by name + - Enter the model name directly + + ``` + │ + ◇ Model fetch complete + │ + ◇ Select a model: + // highlight-start + │ claude-sonnet-4-5 (default) + // highlight-end + │ + ◒ Checking your configuration... + └ Configuration saved successfully + ``` + + This change takes effect the next time you start a session. + + :::tip + Set the model for an individual session using the [`run` command](/docs/guides/goose-cli-commands#run-options): + + ```bash + goose run --model claude-sonnet-4-0 -t "initial prompt" ``` + ::: + diff --git a/documentation/docs/guides/environment-variables.md b/documentation/docs/guides/environment-variables.md index 00c9fa2e4b..0d5c6df44a 100644 --- a/documentation/docs/guides/environment-variables.md +++ b/documentation/docs/guides/environment-variables.md @@ -16,8 +16,8 @@ These are the minimum required variables to get started with goose. | Variable | Purpose | Values | Default | |----------|---------|---------|---------| -| `GOOSE_PROVIDER` | Specifies the LLM provider to use | [See available providers](/docs/getting-started/providers#available-providers) | None (must be [configured](/docs/getting-started/providers#configure-provider)) | -| `GOOSE_MODEL` | Specifies which model to use from the provider | Model name (e.g., "gpt-4", "claude-sonnet-4-20250514") | None (must be configured) | +| `GOOSE_PROVIDER` | Specifies the LLM provider to use | [See available providers](/docs/getting-started/providers#available-providers) | None (must be [configured](/docs/getting-started/providers#configure-provider-and-model)) | +| `GOOSE_MODEL` | Specifies which model to use from the provider | Model name (e.g., "gpt-4", "claude-sonnet-4-20250514") | None (must be [configured](/docs/getting-started/providers#configure-provider-and-model)) | | `GOOSE_TEMPERATURE` | Sets the [temperature](https://medium.com/@kelseyywang/a-comprehensive-guide-to-llm-temperature-%EF%B8%8F-363a40bbc91f) for model responses | Float between 0.0 and 1.0 | Model-specific default | **Examples** diff --git a/documentation/docs/guides/handling-llm-rate-limits-with-goose.md b/documentation/docs/guides/handling-llm-rate-limits-with-goose.md index a8d431fca8..42bcdbd21e 100644 --- a/documentation/docs/guides/handling-llm-rate-limits-with-goose.md +++ b/documentation/docs/guides/handling-llm-rate-limits-with-goose.md @@ -11,7 +11,7 @@ import { PanelLeft } from 'lucide-react'; Rate limiting is the process of restricting the number of requests a user or application can send to an LLM API within a specific timeframe. LLM providers enforce this with the purpose of managing resources and preventing abuse. -Since goose is working very quickly to implement your tasks, you may need to manage rate limits imposed by the provider. If you frequently hit rate limits, consider upgrading your LLM plan to access higher tier limits or [configure a provider](/docs/getting-started/providers#configure-provider) that has built-in rate limiting: +Since goose is working very quickly to implement your tasks, you may need to manage rate limits imposed by the provider. If you frequently hit rate limits, consider upgrading your LLM plan to access higher tier limits or [configure a provider](/docs/getting-started/providers#configure-provider-and-model) that has built-in rate limiting: :::info goose supports automatic setup for both providers that takes you through OAuth account creation and secure API key generation. From f71d2199fc92b1663f517b202084563058e24640 Mon Sep 17 00:00:00 2001 From: Zane <75694352+zanesq@users.noreply.github.com> Date: Mon, 8 Dec 2025 14:37:11 -0800 Subject: [PATCH 05/33] Hide recipe icon in empty chat (#6022) --- ui/desktop/src/components/ChatInput.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index ecf8fe1170..3b972dc92e 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -1507,7 +1507,7 @@ export default function ChatInput({ )} - {sessionId && ( + {sessionId && messages.length > 0 && ( <>
From 4438a169184fb2e2b55c9b82e36ecf5c5e377ee9 Mon Sep 17 00:00:00 2001 From: Zane <75694352+zanesq@users.noreply.github.com> Date: Mon, 8 Dec 2025 19:41:30 -0800 Subject: [PATCH 06/33] Added search bar / filtering for recipes (#6019) --- .../src/components/recipes/RecipesView.tsx | 53 +++++++++++++++---- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/ui/desktop/src/components/recipes/RecipesView.tsx b/ui/desktop/src/components/recipes/RecipesView.tsx index d8e62625bc..3c278451df 100644 --- a/ui/desktop/src/components/recipes/RecipesView.tsx +++ b/ui/desktop/src/components/recipes/RecipesView.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { listSavedRecipes, convertToLocaleDateString } from '../../recipe/recipe_management'; import { FileText, @@ -32,6 +32,7 @@ import { generateDeepLink, Recipe } from '../../recipe'; import { useNavigation } from '../../hooks/useNavigation'; import { CronPicker } from '../schedule/CronPicker'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog'; +import { SearchView } from '../conversation/SearchView'; import cronstrue from 'cronstrue'; export default function RecipesView() { @@ -57,6 +58,26 @@ export default function RecipesView() { const [slashCommand, setSlashCommand] = useState(''); const [scheduleValid, setScheduleIsValid] = useState(true); + const [searchTerm, setSearchTerm] = useState(''); + + const filteredRecipes = useMemo(() => { + if (!searchTerm) return savedRecipes; + + const searchLower = searchTerm.toLowerCase(); + return savedRecipes.filter((recipeManifest) => { + const { recipe, slash_command } = recipeManifest; + const title = recipe.title?.toLowerCase() || ''; + const description = recipe.description?.toLowerCase() || ''; + const slashCmd = slash_command?.toLowerCase() || ''; + + return ( + title.includes(searchLower) || + description.includes(searchLower) || + slashCmd.includes(searchLower) + ); + }); + }, [savedRecipes, searchTerm]); + useEffect(() => { loadSavedRecipes(); }, []); @@ -485,9 +506,19 @@ export default function RecipesView() { ); } + if (filteredRecipes.length === 0 && searchTerm) { + return ( +
+ +

No matching recipes found

+

Try adjusting your search terms

+
+ ); + } + return (
- {savedRecipes.map((recipeManifestResponse: RecipeManifest) => ( + {filteredRecipes.map((recipeManifestResponse: RecipeManifest) => (

View and manage your saved recipes to quickly start new sessions with predefined - configurations. + configurations. ⌘F/Ctrl+F to search.

-
- {renderContent()} -
+ setSearchTerm(term)} placeholder="Search recipes..."> +
+ {renderContent()} +
+
From c507ff8b8f0b9ecefceeaf207244869ab6249d2e Mon Sep 17 00:00:00 2001 From: Zane <75694352+zanesq@users.noreply.github.com> Date: Mon, 8 Dec 2025 19:42:01 -0800 Subject: [PATCH 07/33] remove problematic corrupted woff font (#6006) --- ui/desktop/src/styles/main.css | 28 +++------------------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/ui/desktop/src/styles/main.css b/ui/desktop/src/styles/main.css index f24dffddf0..930288787d 100644 --- a/ui/desktop/src/styles/main.css +++ b/ui/desktop/src/styles/main.css @@ -49,7 +49,7 @@ :root { /* fonts */ --font-sans: 'Cash Sans', sans-serif; - --font-mono: 'Cash Sans Mono', monospace; + --font-mono: monospace; /* theming accents */ --background-accent: var(--color-accent); @@ -192,7 +192,7 @@ --color-text-info: var(--text-info); --font-sans: 'Cash Sans', sans-serif; - --font-mono: 'Cash Sans Mono', monospace; + --font-mono: monospace; --font-serif: serif; --color-ring: var(--ring); @@ -258,28 +258,6 @@ font-style: normal; } -@font-face { - font-family: 'Cash Sans Mono'; - src: - url(https://cash-f.squarecdn.com/static/fonts/cashsans/woff2/CashSansMono-Regular.woff2) - format('woff2'), - url(https://cash-f.squarecdn.com/static/fonts/cashsans/woff/CashSansMono-Regular.woff) - format('woff'); - font-weight: 500; - font-style: normal; -} - -@font-face { - font-family: 'Cash Sans Mono'; - src: - url(https://cash-f.squarecdn.com/static/fonts/cashsans/woff2/CashSansMono-Light.woff2) - format('woff2'), - url(https://cash-f.squarecdn.com/static/fonts/cashsans/woff/CashSansMono-Light.woff) - format('woff'); - font-weight: 300; - font-style: normal; -} - /* animations */ @keyframes appear { from { @@ -522,7 +500,7 @@ body { .bg-inline-code { border-radius: 4px; color: var(--color-neutral-200); - font-family: 'Cash Sans Mono', monospace; + font-family: monospace; font-size: 12px; font-style: normal; font-weight: 400; From 50e50f6d20ac5011025807b61b02dde984fca442 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 04:12:52 -0500 Subject: [PATCH 08/33] chore(deps): bump mdast-util-to-hast from 13.2.0 to 13.2.1 in /documentation (#5963) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- documentation/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/documentation/package-lock.json b/documentation/package-lock.json index cb0412efe6..c54de3bc88 100644 --- a/documentation/package-lock.json +++ b/documentation/package-lock.json @@ -11396,9 +11396,9 @@ } }, "node_modules/mdast-util-to-hast": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", - "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", From 704985e24f810c6a2d104e2a001601d7c057e1ca Mon Sep 17 00:00:00 2001 From: Ebony Louis <55366651+EbonyLouis@users.noreply.github.com> Date: Tue, 9 Dec 2025 11:13:45 -0500 Subject: [PATCH 09/33] Cleanup: Remove Recipe Key Flow (#6015) --- .github/ISSUE_TEMPLATE/submit-recipe.yml | 11 - .github/pull_request_template.md | 3 - .github/scripts/send_key.py | 229 -------------------- .github/workflows/send-api-key.yml | 71 ------ .github/workflows/validate-recipe-pr.yml | 265 ----------------------- 5 files changed, 579 deletions(-) delete mode 100644 .github/scripts/send_key.py delete mode 100644 .github/workflows/send-api-key.yml delete mode 100644 .github/workflows/validate-recipe-pr.yml diff --git a/.github/ISSUE_TEMPLATE/submit-recipe.yml b/.github/ISSUE_TEMPLATE/submit-recipe.yml index c9b6b0d5aa..b2af2ae9e5 100644 --- a/.github/ISSUE_TEMPLATE/submit-recipe.yml +++ b/.github/ISSUE_TEMPLATE/submit-recipe.yml @@ -16,9 +16,7 @@ body: 🪄 **What Happens After?** - If accepted, we'll publish your recipe to the [goose recipes cookbook](https://block.github.io/goose/recipes) - - You'll receive OpenRouter **LLM API credits** as a thank you! - Your GitHub handle will be displayed and linked on the recipe card - - If you provide an email below, we'll email you your credits when your recipe is approved and merged. - If the YAML has any issues, goose will comment with validation errors so you can fix and resubmit. 🧪 **Pro Tip:** You can test your recipe locally in your terminal with: @@ -86,15 +84,6 @@ body: validations: required: true - - type: input - id: email - attributes: - label: Your Email (optional) - description: If your recipe is approved, we'll email your LLM API credits here. - placeholder: yourname@example.com - validations: - required: false - - type: markdown attributes: value: | diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index bc90ec22d9..b7cd0bdd4b 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -31,6 +31,3 @@ Before: After: -### Submitting a Recipe? - -**Email**: diff --git a/.github/scripts/send_key.py b/.github/scripts/send_key.py deleted file mode 100644 index 554873eadd..0000000000 --- a/.github/scripts/send_key.py +++ /dev/null @@ -1,229 +0,0 @@ -import os -import requests -import re -import email_validator -from sendgrid import SendGridAPIClient -from sendgrid.helpers.mail import Mail -from python_http_client.exceptions import HTTPError - - -def main(): - # Environment variable validation - required_envs = ["GITHUB_TOKEN", "GITHUB_SHA", "GITHUB_REPOSITORY", "PROVISIONING_API_KEY", "EMAIL_API_KEY"] - missing = [env for env in required_envs if env not in os.environ] - if missing: - print(f"❌ Missing environment variables: {', '.join(missing)}") - exit(2) - - GITHUB_TOKEN = os.environ["GITHUB_TOKEN"] - GITHUB_SHA = os.environ["GITHUB_SHA"] - REPO_NAME = os.environ["GITHUB_REPOSITORY"] - PROVISIONING_API_KEY = os.environ["PROVISIONING_API_KEY"] - SENDGRID_API_KEY = os.environ["EMAIL_API_KEY"] - - pr_number = get_pr_number_from_sha() - pr_data = fetch_pr_body(pr_number, GITHUB_TOKEN, REPO_NAME) - pr_body = pr_data.get("body", "") - - email = extract_email(pr_body, REPO_NAME, pr_number, GITHUB_TOKEN) - print(f"📬 Found email: {email}") - - try: - api_key = provision_api_key(PROVISIONING_API_KEY) - print("✅ API key generated!") - - if not send_email(email, api_key, SENDGRID_API_KEY): - print("❌ Email failed to send. Exiting without PR comment.") - exit(2) - - comment_on_pr(GITHUB_TOKEN, REPO_NAME, pr_number, email) - - except Exception as err: - print(f"❌ An error occurred: {err}") - exit(2) - -def get_pr_number_from_sha(): - token = os.getenv("GITHUB_TOKEN") - repo = os.getenv("GITHUB_REPOSITORY") - sha = os.getenv("GITHUB_SHA") - - url = f"https://api.github.com/repos/{repo}/commits/{sha}/pulls" - headers = { - "Authorization": f"token {token}", - "Accept": "application/vnd.github.groot-preview+json" - } - - response = requests.get(url, headers=headers) - response.raise_for_status() - - pr_data = response.json() - if pr_data: - return pr_data[0]["number"] - else: - raise Exception("No PR found for this SHA") - -def fetch_pr_body(pr_number, github_token, repo_full_name): - print("🔍 Fetching PR body...") - pr_url = f"https://api.github.com/repos/{repo_full_name}/pulls/{pr_number}" - try: - pr_resp = requests.get( - pr_url, - headers={"Authorization": f"Bearer {github_token}"} - ) - pr_resp.raise_for_status() - except requests.exceptions.RequestException as e: - print("❌ Failed to fetch PR body:", str(e)) - raise - return pr_resp.json() - -def extract_email_from_text(text): - email_match = re.search(r"\*\*Email\*\*:\s*([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})", text) - if email_match: - return email_match.group(1) - email_match = re.search(r"[Ee]mail:\s*([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})", text) - if email_match: - return email_match.group(1) - email_match = re.search(r"\b([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})\b", text) - if email_match: - return email_match.group(1) - return None - -def fetch_pr_comments(repo_full_name, pr_number, github_token): - comments_url = f"https://api.github.com/repos/{repo_full_name}/issues/{pr_number}/comments" - try: - comments_resp = requests.get( - comments_url, - headers={"Authorization": f"Bearer {github_token}"} - ) - comments_resp.raise_for_status() - return comments_resp.json() - except requests.exceptions.RequestException as e: - print(f"⚠️ Failed to fetch PR comments: {e}") - return [] - -def validate_email_address(email): - try: - valid_email = email_validator.validate_email(email) - normalized_email = valid_email.email - print(f"✅ Email validation passed: {normalized_email}") - return normalized_email - except email_validator.EmailNotValidError as e: - print(f"❌ Email validation failed: {e}") - return None - -def extract_email(pr_body, repo_full_name, pr_number, github_token): - print("🔍 Searching for email in PR body...") - email = extract_email_from_text(pr_body) - if email: - print(f"📧 Found email in PR body: {email}") - validated_email = validate_email_address(email) - if validated_email: - return validated_email - else: - print("⚠️ Email in PR body is invalid, checking comments...") - - print("🔍 No valid email found in PR body, checking comments...") - comments = fetch_pr_comments(repo_full_name, pr_number, github_token) - for comment in comments: - comment_body = comment.get("body", "") - email = extract_email_from_text(comment_body) - if email: - print(f"📧 Found email in comment by {comment.get('user', {}).get('login', 'unknown')}: {email}") - validated_email = validate_email_address(email) - if validated_email: - return validated_email - else: - print("⚠️ Email in comment is invalid, continuing search...") - - print("❌ No valid email found in PR body or comments. Skipping key issuance.") - exit(2) - -def provision_api_key(provisioning_api_key): - print("🔐 Creating OpenRouter key...") - try: - key_resp = requests.post( - "https://openrouter.ai/api/v1/keys", - headers={ - "Authorization": f"Bearer {provisioning_api_key}", - "Content-Type": "application/json" - }, - json={ - "name": "goose contributor", - "label": "goose-cookbook", - "limit": 10.0 - } - ) - key_resp.raise_for_status() - except requests.exceptions.RequestException as e: - print("❌ Failed to provision API key:", str(e)) - raise - key = key_resp.json().get("key") - if not key: - print("❌ API response did not include a key.") - exit(2) - return key - -def send_email(email, api_key, sendgrid_api_key): - print("📤 Sending email via SendGrid...") - try: - sg = SendGridAPIClient(sendgrid_api_key) - from_email = "goose team " - subject = "🎉 Your goose contributor API key" - html_content = f""" -

Thank you for contributing to the goose recipe cookbook!

-

🎉 Here's your $10 OpenRouter API key:

-
{api_key}
-

To use this in goose (CLI or Desktop):

-
    -
  • Go to your Provider Settings
  • -
  • Select OpenRouter from the provider list
  • -
  • Paste your API key
  • -
-

📚 Full setup instructions:
- - https://block.github.io/goose/docs/getting-started/providers/#configure-provider

-

Happy coding!
– the goose team

- """ - message = Mail( - from_email=from_email, - to_emails=email, - subject=subject, - html_content=html_content - ) - response = sg.send(message) - print(f"✅ Email sent successfully! Status code: {response.status_code}") - if response.status_code >= 300: - print(f"⚠️ Warning: Unexpected status code {response.status_code}") - print(f"Response body: {response.body}") - return False - return True - - except HTTPError as e: - print(f"❌ SendGrid HTTP error {e.status_code}: {e.body}") - return False - except Exception as e: - print(f"❌ Unexpected error sending email: {type(e).__name__}: {e}") - return False - -def comment_on_pr(github_token, repo_full_name, pr_number, email): - print("💬 Commenting on PR...") - comment_url = f"https://api.github.com/repos/{repo_full_name}/issues/{pr_number}/comments" - try: - comment_resp = requests.post( - comment_url, - headers={ - "Authorization": f"Bearer {github_token}", - "Accept": "application/vnd.github+json" - }, - json={ - "body": f"✅ $10 OpenRouter API key sent to `{email}`. Thanks for your contribution to the goose cookbook!" - } - ) - comment_resp.raise_for_status() - print("✅ Confirmation comment added to PR.") - except requests.exceptions.RequestException as e: - print("❌ Failed to comment on PR:", str(e)) - raise - -if __name__ == "__main__": - main() diff --git a/.github/workflows/send-api-key.yml b/.github/workflows/send-api-key.yml deleted file mode 100644 index 5b1144acaa..0000000000 --- a/.github/workflows/send-api-key.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: Send API Key on PR Merge - -on: - pull_request_target: - types: [closed] - paths: - - 'documentation/src/pages/recipes/data/recipes/**' - -permissions: - contents: read - issues: write - pull-requests: write - -jobs: - send-api-key: - if: github.event.pull_request.merged == true - runs-on: ubuntu-latest - - steps: - - name: Checkout repo at merge commit - uses: actions/checkout@v3 - with: - ref: ${{ github.event.pull_request.merge_commit_sha }} - fetch-depth: 0 - - - name: Check if recipe files were added or modified in merged PR - id: recipe_changes - run: | - set -e - echo "🔍 Checking if recipe files were added or modified in merged PR..." - - MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}" - echo "Merge commit: $MERGE_COMMIT" - - # Get parent commit of the merge - PARENT_COMMIT=$(git rev-parse "$MERGE_COMMIT^1") - echo "Parent commit: $PARENT_COMMIT" - - # Get list of added or modified files in the PR (ignore deletions) - CHANGED_FILES=$(git diff --name-only --diff-filter=AM "$PARENT_COMMIT" "$MERGE_COMMIT") - - echo "Files added/modified in merged PR:" - echo "$CHANGED_FILES" - echo "" - - # Detect recipe changes only - if echo "$CHANGED_FILES" | grep -q "^documentation/src/pages/recipes/data/recipes/"; then - echo "recipe_files_changed=true" >> "$GITHUB_OUTPUT" - echo "✅ Recipe files were added/modified in merged PR - proceeding with API key sending" - else - echo "recipe_files_changed=false" >> "$GITHUB_OUTPUT" - echo "ℹ️ No recipe files were added/modified in merged PR - skipping API key sending" - fi - - - name: Set up Python - if: steps.recipe_changes.outputs.recipe_files_changed == 'true' - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - - name: Install dependencies and run email script - if: steps.recipe_changes.outputs.recipe_files_changed == 'true' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITHUB_SHA: ${{ github.sha }} - GITHUB_REPOSITORY: ${{ github.repository }} - PROVISIONING_API_KEY: ${{ secrets.PROVISIONING_API_KEY }} - EMAIL_API_KEY: ${{ secrets.SENDGRID_API_KEY }} - run: | - pip install requests sendgrid email-validator - python .github/scripts/send_key.py diff --git a/.github/workflows/validate-recipe-pr.yml b/.github/workflows/validate-recipe-pr.yml deleted file mode 100644 index 04529c7f63..0000000000 --- a/.github/workflows/validate-recipe-pr.yml +++ /dev/null @@ -1,265 +0,0 @@ -name: Validate Recipe PR - -on: - pull_request_target: - types: [opened, synchronize, reopened] - paths: - - 'documentation/src/pages/recipes/data/recipes/**' - -permissions: - contents: read - pull-requests: write - issues: write - -jobs: - validate-recipe: - runs-on: ubuntu-latest - - env: - PROVIDER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} - - steps: - - name: Checkout PR - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 0 - - - name: Set up Node.js - uses: actions/setup-node@v3 - with: - node-version: '20' - - - name: Install and Configure goose - run: | - mkdir -p /home/runner/.local/bin - curl -fsSL https://github.com/block/goose/releases/download/stable/download_cli.sh \ - | CONFIGURE=false INSTALL_PATH=/home/runner/.local/bin bash - echo "/home/runner/.local/bin" >> $GITHUB_PATH - - mkdir -p ~/.config/goose - cat < ~/.config/goose/config.yaml - GOOSE_PROVIDER: openrouter - GOOSE_MODEL: "anthropic/claude-sonnet-4" - keyring: false - EOF - - - name: Check if recipe files changed in this push - id: recipe_changes - run: | - set -e - echo "🔍 Checking if recipe files were modified in this push..." - - # Get the list of changed files in this specific push (added/modified only, not deleted) - if [ "${{ github.event_name }}" = "pull_request" ] && [ "${{ github.event.action }}" = "synchronize" ]; then - # For synchronize events, check files changed since the previous commit - echo "📝 Synchronize event - checking files changed since previous commit" - CHANGED_FILES=$(git diff --name-only --diff-filter=AM ${{ github.event.before }}..${{ github.event.after }}) - else - # For opened/reopened, check all files in the PR (compare PR head against base) - echo "📝 PR opened/reopened - checking all files in PR" - CHANGED_FILES=$(git diff --name-only --diff-filter=AM ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}) - fi - - echo "Changed files in this push:" - echo "$CHANGED_FILES" - echo "" - - # Check if any recipe files were changed - if echo "$CHANGED_FILES" | grep -q "^documentation/src/pages/recipes/data/recipes/"; then - echo "recipe_files_changed=true" >> "$GITHUB_OUTPUT" - echo "✅ Recipe files were modified in this push - proceeding with validation" - else - echo "recipe_files_changed=false" >> "$GITHUB_OUTPUT" - echo "ℹ️ No recipe files were modified in this push - skipping validation" - fi - - - name: Find recipe files in PR (new or modified) - id: find_changed_recipes - if: steps.recipe_changes.outputs.recipe_files_changed == 'true' - run: | - set -e - echo "Looking for recipe files in PR (new or modified)..." - - # Get the list of changed/new files in this PR (added/modified only, not deleted) - if [ "${{ github.event_name }}" = "pull_request" ] && [ "${{ github.event.action }}" = "synchronize" ]; then - # For synchronize events, check files changed since the previous commit - echo "📝 Synchronize event - checking files changed/added since previous commit" - CHANGED_FILES=$(git diff --name-only --diff-filter=AM ${{ github.event.before }}..${{ github.event.after }}) - else - # For opened/reopened, check all files in the PR (compare PR head against base) - echo "📝 PR opened/reopened - checking all new/modified files in PR" - CHANGED_FILES=$(git diff --name-only --diff-filter=AM ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}) - fi - - # Filter for recipe files only that were changed or added - RECIPE_FILES=$(echo "$CHANGED_FILES" | grep "^documentation/src/pages/recipes/data/recipes/" | grep -E "\.(yaml|yml)$" || true) - - if [ -z "$RECIPE_FILES" ]; then - echo "No changed recipe files found in PR" - echo "validation_status=no_files" >> $GITHUB_OUTPUT - exit 1 - fi - - echo "Found changed recipe files:" - echo "$RECIPE_FILES" - - # Save recipe file paths for validation step - echo "$RECIPE_FILES" > /tmp/changed_recipe_files.txt - - - name: Validate changed recipe files - id: validate - if: steps.recipe_changes.outputs.recipe_files_changed == 'true' - run: | - set -e - # Read the list of changed recipe files - RECIPE_FILES=$(cat /tmp/changed_recipe_files.txt) - - ALL_VALID=true - VALIDATION_OUTPUT="" - - # First pass: Basic YAML validation - while IFS= read -r RECIPE_FILE; do - if [ -f "$RECIPE_FILE" ]; then - BASE_RECIPE_FILENAME=$(basename "$RECIPE_FILE") - echo "🔍 Validating: $BASE_RECIPE_FILENAME" - if OUTPUT=$(goose recipe validate "$RECIPE_FILE" 2>&1); then - echo "✅ Valid: $BASE_RECIPE_FILENAME" - VALIDATION_OUTPUT="${VALIDATION_OUTPUT}✅ $BASE_RECIPE_FILENAME: VALID\n" - else - echo "❌ Invalid: $BASE_RECIPE_FILENAME" - echo "$OUTPUT" - VALIDATION_OUTPUT="${VALIDATION_OUTPUT}❌ $BASE_RECIPE_FILENAME: INVALID\n\`\`\`\n$OUTPUT\n\`\`\`\n" - ALL_VALID=false - fi - fi - done < /tmp/changed_recipe_files.txt - - # Second pass: Check for duplicate filenames - if [ "$ALL_VALID" = true ]; then - echo "🔍 Checking for duplicate filenames..." - - # Check for duplicate filenames first - SEEN_FILENAMES="" - while IFS= read -r RECIPE_FILE; do - if [ -f "$RECIPE_FILE" ]; then - FILENAME=$(basename "$RECIPE_FILE" .yaml) - FILENAME=$(basename "$FILENAME" .yml) - - echo "📋 Checking filename: '$FILENAME'" - - # Check if we've seen this filename before in this PR - if echo "$SEEN_FILENAMES" | grep -q "^$FILENAME$"; then - echo "❌ Duplicate filename '$FILENAME' found in this PR" - VALIDATION_OUTPUT="${VALIDATION_OUTPUT}❌ Duplicate filename '$FILENAME' found in this PR\n" - ALL_VALID=false - else - SEEN_FILENAMES="$SEEN_FILENAMES\n$FILENAME" - fi - - # Check if this is a new file or an update to existing file - # Get list of changed files in this PR compared to base branch - CHANGED_FILES=$(git diff --name-only --diff-filter=AM ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} | grep "^$RECIPE_FILE$" || true) - EXISTING_FILES=$(find documentation/src/pages/recipes/data/recipes/ -name "$FILENAME.yaml" -o -name "$FILENAME.yml" | grep -v "^$RECIPE_FILE$" || true) - - if [ -n "$EXISTING_FILES" ] && [ -z "$CHANGED_FILES" ]; then - # File exists in repo but is not being modified - this is a new duplicate - echo "❌ Recipe filename '$FILENAME' already exists:" - echo "$EXISTING_FILES" - VALIDATION_OUTPUT="${VALIDATION_OUTPUT}❌ $RECIPE_FILE: Filename '$FILENAME' already exists in: $EXISTING_FILES\n" - ALL_VALID=false - elif [ -n "$EXISTING_FILES" ] && [ -n "$CHANGED_FILES" ]; then - # File exists and is being modified - this is an update - echo "✅ Updating existing recipe: '$FILENAME'" - else - # File doesn't exist - this is a new recipe - echo "✅ New recipe filename '$FILENAME' is unique" - fi - - echo "✅ Filename '$FILENAME' validation complete" - fi - done < /tmp/changed_recipe_files.txt - fi - - # Save validation output for use in comment - echo "$VALIDATION_OUTPUT" > /tmp/validation_output.txt - - if [ "$ALL_VALID" = true ]; then - echo "validation_status=valid" >> $GITHUB_OUTPUT - else - echo "validation_status=invalid" >> $GITHUB_OUTPUT - fi - - - name: Comment validation results - if: steps.recipe_changes.outputs.recipe_files_changed == 'true' - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const fs = require('fs'); - const status = '${{ steps.validate.outputs.validation_status }}'; - - let comment; - if (status === 'no_files') { - comment = `❌ **Recipe Validation Failed** - - No recipe files found in the correct location! - - 📁 **Please add your recipe to**: \`documentation/src/pages/recipes/data/recipes/your-recipe-id.yaml\` - - **Example**: If your recipe ID is \`web-scraper\`, create: - \`documentation/src/pages/recipes/data/recipes/web-scraper.yaml\``; - } else if (status === 'valid') { - comment = `✅ **Recipe Validation Passed** - - Your recipe(s) are valid and ready for review! - - 🔍 **Next Steps**: - 1. Our team will review your recipe - 2. If approved, we'll run a security scan - 3. Once merged, you'll receive $10 in OpenRouter credits (if email provided) - - Thanks for contributing to the goose Recipe Cookbook! 🎉`; - } else { - // Read validation details from file - let validationDetails = ''; - try { - validationDetails = fs.readFileSync('/tmp/validation_output.txt', 'utf8'); - } catch (e) { - validationDetails = 'See workflow logs for details.'; - } - - comment = `❌ **Recipe Validation Failed** - - Please fix the validation errors and push your changes: - - ${validationDetails} - - 📚 Check our [Recipe Guide](https://block.github.io/goose/recipes) for help with the correct format.`; - } - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - body: comment - }); - - - name: Set validation status - if: always() - run: | - # Check if recipe files were changed in this PR - if [ "${{ steps.recipe_changes.outputs.recipe_files_changed }}" = "false" ]; then - # No recipe files were modified in this PR - validation skipped - echo "ℹ️ No recipe files in PR - validation skipped" - exit 0 - fi - - VALIDATION_STATUS="${{ steps.validate.outputs.validation_status }}" - if [ "$VALIDATION_STATUS" = "valid" ]; then - echo "✅ All recipes are valid" - exit 0 - else - echo "❌ Recipe validation failed" - exit 1 - fi \ No newline at end of file From 02189f5555e929b212a18d83d479dbe1a3f26d7c Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Tue, 9 Dec 2025 12:28:51 -0500 Subject: [PATCH 10/33] gov: new LF Projects LLC section (#6027) --- GOVERNANCE.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/GOVERNANCE.md b/GOVERNANCE.md index a9e5770999..ee6a912c49 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -187,3 +187,13 @@ Core Maintainers and Maintainers are listed in the main goose repository's [MAIN * **Quality**: Thoughtful review while avoiding bureaucracy We believe this balance enables goose to remain innovative while building a strong, engaged community around the shared goal of creating the most hackable, user-controlled AI agent available. + +# General Project Policies + +Founded by Block, goose has been established as a Series of LF Projects, LLC. Policies applicable to goose and participants in the goose project, including guidelines on the usage of trademarks, are located at [https://lfprojects.org/policies/](https://lfprojects.org/policies/). Governance changes approved as per the provisions of this governance document must also be approved by LF Projects, LLC. + +goose participants acknowledge that the copyright in all new contributions will be retained by the copyright holder as independent works of authorship and that no contributor or copyright holder will be required to assign copyrights to the project. +Except as described below, all code and specification contributions to the project must be made using the Apache License, Version 2.0 available at (the “Project License”). + +All outbound code and specifications will be made available under the Project License. The Core Maintainers may approve the use of an alternative open license or licenses for inbound or outbound contributions on an exception basis. +All documentation (excluding specifications) will be made available under the Creative Commons Attribution 4.0 International license, available at: https://creativecommons.org/licenses/by/4.0. From 9b6a1c1297ffe9b59638892bdb5493436617ef4b Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Wed, 10 Dec 2025 11:28:18 +1100 Subject: [PATCH 11/33] fix: metrics on posthog (#6024) Co-authored-by: Zane Staggs Co-authored-by: Zane <75694352+zanesq@users.noreply.github.com> --- Cargo.lock | 51 ++- crates/goose-cli/src/session/builder.rs | 2 + crates/goose-server/src/routes/agent.rs | 4 + crates/goose/Cargo.toml | 1 + crates/goose/src/agents/agent.rs | 10 +- crates/goose/src/agents/tool_route_manager.rs | 6 +- crates/goose/src/lib.rs | 1 + crates/goose/src/posthog.rs | 303 ++++++++++++++++++ crates/goose/src/providers/errors.rs | 15 + crates/goose/src/session/session_manager.rs | 5 +- ui/desktop/src/App.tsx | 2 + ui/desktop/src/components/ProviderGuard.tsx | 4 + .../src/components/TelemetryOptOutModal.tsx | 131 ++++++++ ui/desktop/src/components/icons/Anthropic.tsx | 4 +- ui/desktop/src/components/icons/Key.tsx | 7 +- ui/desktop/src/components/icons/Tetrate.tsx | 8 +- .../settings/app/AppSettingsSection.tsx | 5 +- .../settings/app/TelemetrySettings.tsx | 128 ++++++++ .../models/subcomponents/SwitchModelModal.tsx | 35 +- ui/desktop/src/updates.ts | 1 + 20 files changed, 689 insertions(+), 34 deletions(-) create mode 100644 crates/goose/src/posthog.rs create mode 100644 ui/desktop/src/components/TelemetryOptOutModal.tsx create mode 100644 ui/desktop/src/components/settings/app/TelemetrySettings.tsx diff --git a/Cargo.lock b/Cargo.lock index 0ae2dd1b46..c81ab25ef4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1964,6 +1964,37 @@ dependencies = [ "syn 2.0.99", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.10", + "proc-macro2", + "quote", + "syn 2.0.99", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.99", +] + [[package]] name = "devgen-tree-sitter-swift" version = "0.21.0" @@ -2025,7 +2056,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -2679,6 +2710,7 @@ dependencies = [ "opentelemetry-otlp", "opentelemetry_sdk", "paste", + "posthog-rs", "rand 0.8.5", "regex", "reqwest 0.12.12", @@ -4996,6 +5028,21 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "posthog-rs" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "610d236077c94c96194b0d2b40989bda4bf8faf60e15a0da6ef8ffd4c78f6c1c" +dependencies = [ + "chrono", + "derive_builder", + "reqwest 0.11.27", + "semver", + "serde", + "serde_json", + "uuid", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -5545,6 +5592,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots 0.25.4", "winreg 0.50.0", ] @@ -7740,6 +7788,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0f540e3240398cce6128b64ba83fdbdd86129c16a3aa1a3a252efd66eb3d587" dependencies = [ "getrandom 0.3.1", + "serde", ] [[package]] diff --git a/crates/goose-cli/src/session/builder.rs b/crates/goose-cli/src/session/builder.rs index 8c9fc96613..947e2d3349 100644 --- a/crates/goose-cli/src/session/builder.rs +++ b/crates/goose-cli/src/session/builder.rs @@ -248,6 +248,8 @@ pub struct SessionSettings { } pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { + goose::posthog::set_session_context("cli", session_config.resume); + let config = Config::global(); let (saved_provider, saved_model_config) = if session_config.resume { diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index fd1b63eec3..e6f8e57474 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -105,6 +105,8 @@ async fn start_agent( State(state): State>, Json(payload): Json, ) -> Result, ErrorResponse> { + goose::posthog::set_session_context("desktop", false); + let StartAgentRequest { working_dir, recipe, @@ -197,6 +199,8 @@ async fn resume_agent( State(state): State>, Json(payload): Json, ) -> Result, ErrorResponse> { + goose::posthog::set_session_context("desktop", true); + let session = SessionManager::get_session(&payload.session_id, true) .await .map_err(|err| { diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index b56c1ee617..454761d81a 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -111,6 +111,7 @@ shellexpand = "3.1.1" indexmap = "2.12.0" ignore = "0.4.25" which = "8.0.0" +posthog-rs = "0.3.7" [target.'cfg(target_os = "windows")'.dependencies] diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index be04c7100d..ef4d5f6919 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -1221,7 +1221,8 @@ impl Agent { no_tools_called = false; } } - Err(ProviderError::ContextLengthExceeded(_error_msg)) => { + Err(ref provider_err @ ProviderError::ContextLengthExceeded(_)) => { + crate::posthog::emit_error(provider_err.telemetry_type()); yield AgentEvent::Message( Message::assistant().with_system_notification( SystemNotificationType::InlineMessage, @@ -1255,11 +1256,12 @@ impl Agent { } } } - Err(e) => { - error!("Error: {}", e); + Err(ref provider_err) => { + crate::posthog::emit_error(provider_err.telemetry_type()); + error!("Error: {}", provider_err); yield AgentEvent::Message( Message::assistant().with_text( - format!("Ran into this error: {e}.\n\nPlease retry if you think this is a transient or recoverable error.") + format!("Ran into this error: {provider_err}.\n\nPlease retry if you think this is a transient or recoverable error.") ) ); break; diff --git a/crates/goose/src/agents/tool_route_manager.rs b/crates/goose/src/agents/tool_route_manager.rs index 6708060702..363a5880e7 100644 --- a/crates/goose/src/agents/tool_route_manager.rs +++ b/crates/goose/src/agents/tool_route_manager.rs @@ -38,9 +38,9 @@ impl ToolRouteManager { pub async fn record_tool_requests(&self, requests: &[ToolRequest]) { let selector = self.router_tool_selector.lock().await.clone(); - if let Some(selector) = selector { - for request in requests { - if let Ok(tool_call) = &request.tool_call { + for request in requests { + if let Ok(tool_call) = &request.tool_call { + if let Some(ref selector) = selector { if let Err(e) = selector.record_tool_call(&tool_call.name).await { error!("Failed to record tool call: {}", e); } diff --git a/crates/goose/src/lib.rs b/crates/goose/src/lib.rs index 9c21c95dce..5e88df2373 100644 --- a/crates/goose/src/lib.rs +++ b/crates/goose/src/lib.rs @@ -10,6 +10,7 @@ pub mod mcp_utils; pub mod model; pub mod oauth; pub mod permission; +pub mod posthog; pub mod prompt_template; pub mod providers; pub mod recipe; diff --git a/crates/goose/src/posthog.rs b/crates/goose/src/posthog.rs new file mode 100644 index 0000000000..54b5aa8f88 --- /dev/null +++ b/crates/goose/src/posthog.rs @@ -0,0 +1,303 @@ +//! PostHog telemetry - fires once per session creation. + +use crate::config::paths::Paths; +use crate::config::{get_enabled_extensions, Config}; +use crate::session::SessionManager; +use chrono::{DateTime, Utc}; +use once_cell::sync::Lazy; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; +use uuid::Uuid; + +const POSTHOG_API_KEY: &str = "phc_RyX5CaY01VtZJCQyhSR5KFh6qimUy81YwxsEpotAftT"; + +/// Config key for telemetry opt-out preference +pub const TELEMETRY_ENABLED_KEY: &str = "GOOSE_TELEMETRY_ENABLED"; + +static TELEMETRY_DISABLED_BY_ENV: Lazy = Lazy::new(|| { + std::env::var("GOOSE_TELEMETRY_OFF") + .map(|v| v == "1" || v.to_lowercase() == "true") + .unwrap_or(false) + .into() +}); + +/// Check if telemetry is enabled. +/// +/// Returns false if: +/// - GOOSE_TELEMETRY_OFF environment variable is set to "1" or "true" +/// - GOOSE_TELEMETRY_ENABLED config value is set to false +/// +/// Returns true otherwise (telemetry is opt-out, enabled by default) +pub fn is_telemetry_enabled() -> bool { + if TELEMETRY_DISABLED_BY_ENV.load(Ordering::Relaxed) { + return false; + } + + let config = Config::global(); + config + .get_param::(TELEMETRY_ENABLED_KEY) + .unwrap_or(true) +} + +// ============================================================================ +// Installation Tracking +// ============================================================================ + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct InstallationData { + installation_id: String, + first_seen: DateTime, + session_count: u32, +} + +impl Default for InstallationData { + fn default() -> Self { + Self { + installation_id: Uuid::new_v4().to_string(), + first_seen: Utc::now(), + session_count: 0, + } + } +} + +fn installation_file_path() -> std::path::PathBuf { + Paths::state_dir().join("telemetry_installation.json") +} + +fn load_or_create_installation() -> InstallationData { + let path = installation_file_path(); + + if let Ok(contents) = fs::read_to_string(&path) { + if let Ok(data) = serde_json::from_str::(&contents) { + return data; + } + } + + let data = InstallationData::default(); + save_installation(&data); + data +} + +fn save_installation(data: &InstallationData) { + let path = installation_file_path(); + + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + + if let Ok(json) = serde_json::to_string_pretty(data) { + let _ = fs::write(path, json); + } +} + +fn increment_session_count() -> InstallationData { + let mut data = load_or_create_installation(); + data.session_count += 1; + save_installation(&data); + data +} + +// ============================================================================ +// Platform Info +// ============================================================================ + +fn get_platform_version() -> Option { + #[cfg(target_os = "macos")] + { + std::process::Command::new("sw_vers") + .arg("-productVersion") + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + } + #[cfg(target_os = "linux")] + { + fs::read_to_string("/etc/os-release") + .ok() + .and_then(|content| { + content + .lines() + .find(|line| line.starts_with("VERSION_ID=")) + .map(|line| { + line.trim_start_matches("VERSION_ID=") + .trim_matches('"') + .to_string() + }) + }) + } + #[cfg(target_os = "windows")] + { + std::process::Command::new("cmd") + .args(["/C", "ver"]) + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + None + } +} + +fn detect_install_method() -> String { + let exe_path = std::env::current_exe().ok(); + + if let Some(path) = exe_path { + let path_str = path.to_string_lossy().to_lowercase(); + + if path_str.contains("homebrew") || path_str.contains("/opt/homebrew") { + return "homebrew".to_string(); + } + if path_str.contains(".cargo") { + return "cargo".to_string(); + } + if path_str.contains("applications") || path_str.contains(".app") { + return "desktop".to_string(); + } + } + + if std::env::var("GOOSE_DESKTOP").is_ok() { + return "desktop".to_string(); + } + + "binary".to_string() +} + +// ============================================================================ +// Session Context (set by CLI/Desktop at startup) +// ============================================================================ + +static SESSION_INTERFACE: Lazy>> = Lazy::new(|| Mutex::new(None)); +static SESSION_IS_RESUMED: AtomicBool = AtomicBool::new(false); + +pub fn set_session_context(interface: &str, is_resumed: bool) { + if let Ok(mut iface) = SESSION_INTERFACE.lock() { + *iface = Some(interface.to_string()); + } + SESSION_IS_RESUMED.store(is_resumed, Ordering::Relaxed); +} + +fn get_session_interface() -> String { + SESSION_INTERFACE + .lock() + .ok() + .and_then(|i| i.clone()) + .unwrap_or_else(|| "unknown".to_string()) +} + +fn get_session_is_resumed() -> bool { + SESSION_IS_RESUMED.load(Ordering::Relaxed) +} + +// ============================================================================ +// Telemetry Events +// ============================================================================ + +pub fn emit_session_started() { + if !is_telemetry_enabled() { + return; + } + + let installation = increment_session_count(); + + tokio::spawn(async move { + let _ = send_session_event(&installation).await; + }); +} + +pub fn emit_error(error_type: &str) { + if !is_telemetry_enabled() { + return; + } + + let installation = load_or_create_installation(); + let error_type = error_type.to_string(); + + tokio::spawn(async move { + let _ = send_error_event(&installation, &error_type).await; + }); +} + +async fn send_error_event(installation: &InstallationData, error_type: &str) -> Result<(), String> { + let client = posthog_rs::client(POSTHOG_API_KEY).await; + let mut event = posthog_rs::Event::new("error", &installation.installation_id); + + event.insert_prop("error_type", error_type).ok(); + event.insert_prop("version", env!("CARGO_PKG_VERSION")).ok(); + event.insert_prop("interface", get_session_interface()).ok(); + event.insert_prop("os", std::env::consts::OS).ok(); + event.insert_prop("arch", std::env::consts::ARCH).ok(); + + if let Some(platform_version) = get_platform_version() { + event.insert_prop("platform_version", platform_version).ok(); + } + + let config = Config::global(); + if let Ok(provider) = config.get_param::("GOOSE_PROVIDER") { + event.insert_prop("provider", provider).ok(); + } + if let Ok(model) = config.get_param::("GOOSE_MODEL") { + event.insert_prop("model", model).ok(); + } + + client.capture(event).await.map_err(|e| format!("{:?}", e)) +} + +async fn send_session_event(installation: &InstallationData) -> Result<(), String> { + let client = posthog_rs::client(POSTHOG_API_KEY).await; + let mut event = posthog_rs::Event::new("session_started", &installation.installation_id); + + event.insert_prop("os", std::env::consts::OS).ok(); + event.insert_prop("arch", std::env::consts::ARCH).ok(); + event.insert_prop("version", env!("CARGO_PKG_VERSION")).ok(); + + if let Some(platform_version) = get_platform_version() { + event.insert_prop("platform_version", platform_version).ok(); + } + + event + .insert_prop("install_method", detect_install_method()) + .ok(); + + event.insert_prop("interface", get_session_interface()).ok(); + + event + .insert_prop("is_resumed", get_session_is_resumed()) + .ok(); + + event + .insert_prop("session_number", installation.session_count) + .ok(); + let days_since_install = (Utc::now() - installation.first_seen).num_days(); + event + .insert_prop("days_since_install", days_since_install) + .ok(); + + let config = Config::global(); + if let Ok(provider) = config.get_param::("GOOSE_PROVIDER") { + event.insert_prop("provider", provider).ok(); + } + if let Ok(model) = config.get_param::("GOOSE_MODEL") { + event.insert_prop("model", model).ok(); + } + + let extensions = get_enabled_extensions(); + event.insert_prop("extensions_count", extensions.len()).ok(); + let extension_names: Vec = extensions.iter().map(|e| e.name()).collect(); + event.insert_prop("extensions", extension_names).ok(); + + if let Ok(insights) = SessionManager::get_insights().await { + event + .insert_prop("total_sessions", insights.total_sessions) + .ok(); + event + .insert_prop("total_tokens", insights.total_tokens) + .ok(); + } + + client.capture(event).await.map_err(|e| format!("{:?}", e)) +} diff --git a/crates/goose/src/providers/errors.rs b/crates/goose/src/providers/errors.rs index b6ee4e7431..b72605d944 100644 --- a/crates/goose/src/providers/errors.rs +++ b/crates/goose/src/providers/errors.rs @@ -32,6 +32,21 @@ pub enum ProviderError { NotImplemented(String), } +impl ProviderError { + pub fn telemetry_type(&self) -> &'static str { + match self { + ProviderError::Authentication(_) => "auth", + ProviderError::ContextLengthExceeded(_) => "context_length", + ProviderError::RateLimitExceeded { .. } => "rate_limit", + ProviderError::ServerError(_) => "server", + ProviderError::RequestFailed(_) => "request", + ProviderError::ExecutionError(_) => "execution", + ProviderError::UsageError(_) => "usage", + ProviderError::NotImplemented(_) => "not_implemented", + } + } +} + impl From for ProviderError { fn from(error: anyhow::Error) -> Self { if let Some(reqwest_err) = error.downcast_ref::() { diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 427dc0c409..11ce210ff7 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -120,8 +120,8 @@ pub struct SessionUpdateBuilder { #[derive(Serialize, ToSchema, Debug)] #[serde(rename_all = "camelCase")] pub struct SessionInsights { - total_sessions: usize, - total_tokens: i64, + pub total_sessions: usize, + pub total_tokens: i64, } impl SessionUpdateBuilder { @@ -887,6 +887,7 @@ impl SessionStorage { .await?; tx.commit().await?; + crate::posthog::emit_session_started(); Ok(session) } diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 414b9943c6..13ca9aaef7 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -14,6 +14,7 @@ import { ErrorUI } from './components/ErrorBoundary'; import { ExtensionInstallModal } from './components/ExtensionInstallModal'; import { ToastContainer } from 'react-toastify'; import AnnouncementModal from './components/AnnouncementModal'; +import TelemetryOptOutModal from './components/TelemetryOptOutModal'; import ProviderGuard from './components/ProviderGuard'; import { createSession } from './sessions'; @@ -702,6 +703,7 @@ export default function App() { + ); } diff --git a/ui/desktop/src/components/ProviderGuard.tsx b/ui/desktop/src/components/ProviderGuard.tsx index 227be70e49..7daf9cc702 100644 --- a/ui/desktop/src/components/ProviderGuard.tsx +++ b/ui/desktop/src/components/ProviderGuard.tsx @@ -10,6 +10,7 @@ import { OllamaSetup } from './OllamaSetup'; import ApiKeyTester from './ApiKeyTester'; import { SwitchModelModal } from './settings/models/subcomponents/SwitchModelModal'; import { createNavigationHandler } from '../utils/navigationUtils'; +import TelemetrySettings from './settings/app/TelemetrySettings'; import { Goose, OpenRouter, Tetrate } from './icons'; @@ -306,6 +307,9 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG Go to Provider Settings →
+
+ +
diff --git a/ui/desktop/src/components/TelemetryOptOutModal.tsx b/ui/desktop/src/components/TelemetryOptOutModal.tsx new file mode 100644 index 0000000000..ed7d3b2323 --- /dev/null +++ b/ui/desktop/src/components/TelemetryOptOutModal.tsx @@ -0,0 +1,131 @@ +import { useState, useEffect } from 'react'; +import { BaseModal } from './ui/BaseModal'; +import { Button } from './ui/button'; +import { Goose } from './icons/Goose'; +import { TELEMETRY_UI_ENABLED } from '../updates'; +import { toastService } from '../toasts'; +import { useConfig } from './ConfigContext'; + +const TELEMETRY_CONFIG_KEY = 'GOOSE_TELEMETRY_ENABLED'; + +type TelemetryOptOutModalProps = + | { controlled: false } + | { controlled: true; isOpen: boolean; onClose: () => void }; + +export default function TelemetryOptOutModal(props: TelemetryOptOutModalProps) { + const { read, upsert } = useConfig(); + const isControlled = props.controlled; + const controlledIsOpen = isControlled ? props.isOpen : undefined; + const onClose = isControlled ? props.onClose : undefined; + const [showModal, setShowModal] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + // Only check telemetry choice on first launch in uncontrolled mode + useEffect(() => { + if (isControlled) return; + + const checkTelemetryChoice = async () => { + try { + const provider = await read('GOOSE_PROVIDER', false); + + if (!provider || provider === '') { + return; + } + + const telemetryEnabled = await read(TELEMETRY_CONFIG_KEY, false); + + if (telemetryEnabled === null) { + setShowModal(true); + } + } catch (error) { + console.error('Failed to check telemetry config:', error); + toastService.error({ + title: 'Configuration Error', + msg: 'Failed to check telemetry configuration.', + traceback: error instanceof Error ? error.stack || '' : '', + }); + } + }; + + checkTelemetryChoice(); + }, [isControlled, read]); + + const handleChoice = async (enabled: boolean) => { + setIsLoading(true); + try { + await upsert(TELEMETRY_CONFIG_KEY, enabled, false); + setShowModal(false); + onClose?.(); + } catch (error) { + console.error('Failed to set telemetry preference:', error); + setShowModal(false); + onClose?.(); + } finally { + setIsLoading(false); + } + }; + + if (!TELEMETRY_UI_ENABLED) { + return null; + } + + const isModalOpen = controlledIsOpen !== undefined ? controlledIsOpen : showModal; + + if (!isModalOpen) { + return null; + } + + return ( + + + + + } + > +
+
+ +
+

+ Help improve goose +

+

+ Would you like to help improve goose by sharing anonymous usage data? This helps us + understand how goose is used and identify areas for improvement. +

+
+

What we collect:

+
    +
  • Operating system, version, and architecture
  • +
  • goose version and install method
  • +
  • Provider and model used
  • +
  • Extensions and tool usage counts (names only)
  • +
  • Session metrics (duration, interaction count, token usage)
  • +
  • Error types (e.g., "rate_limit", "auth" - no details)
  • +
+

+ We never collect your conversations, code, tool arguments, error messages, or any + personal data. You can change this setting anytime in Settings → App. +

+
+
+
+ ); +} diff --git a/ui/desktop/src/components/icons/Anthropic.tsx b/ui/desktop/src/components/icons/Anthropic.tsx index 7af92a4172..2d2ca8a284 100644 --- a/ui/desktop/src/components/icons/Anthropic.tsx +++ b/ui/desktop/src/components/icons/Anthropic.tsx @@ -9,8 +9,8 @@ export default function Anthropic({ className = '' }) { aria-hidden="true" className={className} > - diff --git a/ui/desktop/src/components/icons/Key.tsx b/ui/desktop/src/components/icons/Key.tsx index 5b7f559f67..e7ff465db9 100644 --- a/ui/desktop/src/components/icons/Key.tsx +++ b/ui/desktop/src/components/icons/Key.tsx @@ -22,7 +22,12 @@ export function Key({ className = '' }: KeyProps) { - + diff --git a/ui/desktop/src/components/icons/Tetrate.tsx b/ui/desktop/src/components/icons/Tetrate.tsx index 011253c937..7a088f2d23 100644 --- a/ui/desktop/src/components/icons/Tetrate.tsx +++ b/ui/desktop/src/components/icons/Tetrate.tsx @@ -9,12 +9,12 @@ export default function Tetrate({ className = '' }) { aria-hidden="true" className={className} > - - diff --git a/ui/desktop/src/components/settings/app/AppSettingsSection.tsx b/ui/desktop/src/components/settings/app/AppSettingsSection.tsx index f5564521d8..c0b82f9e56 100644 --- a/ui/desktop/src/components/settings/app/AppSettingsSection.tsx +++ b/ui/desktop/src/components/settings/app/AppSettingsSection.tsx @@ -11,6 +11,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../.. import ThemeSelector from '../../GooseSidebar/ThemeSelector'; import BlockLogoBlack from './icons/block-lockup_black.png'; import BlockLogoWhite from './icons/block-lockup_white.png'; +import TelemetrySettings from './TelemetrySettings'; interface AppSettingsSectionProps { scrollToSection?: string; @@ -288,7 +289,7 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti

Prevent Sleep

- Keep your computer awake while Goose is running a task (screen can still lock) + Keep your computer awake while goose is running a task (screen can still lock)

@@ -397,6 +398,8 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti + + Help & feedback diff --git a/ui/desktop/src/components/settings/app/TelemetrySettings.tsx b/ui/desktop/src/components/settings/app/TelemetrySettings.tsx new file mode 100644 index 0000000000..6f24a528c9 --- /dev/null +++ b/ui/desktop/src/components/settings/app/TelemetrySettings.tsx @@ -0,0 +1,128 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Switch } from '../../ui/switch'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card'; +import { useConfig } from '../../ConfigContext'; +import { TELEMETRY_UI_ENABLED } from '../../../updates'; +import TelemetryOptOutModal from '../../TelemetryOptOutModal'; +import { toastService } from '../../../toasts'; + +const TELEMETRY_CONFIG_KEY = 'GOOSE_TELEMETRY_ENABLED'; + +interface TelemetrySettingsProps { + isWelcome: boolean; +} + +export default function TelemetrySettings({ isWelcome = false }: TelemetrySettingsProps) { + const { read, upsert } = useConfig(); + const [telemetryEnabled, setTelemetryEnabled] = useState(true); + const [isLoading, setIsLoading] = useState(true); + const [showModal, setShowModal] = useState(false); + + const loadTelemetryStatus = useCallback(async () => { + try { + const value = await read(TELEMETRY_CONFIG_KEY, false); + setTelemetryEnabled(value === null ? true : Boolean(value)); + } catch (error) { + console.error('Failed to load telemetry status:', error); + toastService.error({ + title: 'Configuration Error', + msg: 'Failed to load telemetry settings.', + traceback: error instanceof Error ? error.stack || '' : '', + }); + } finally { + setIsLoading(false); + } + }, [read]); + + useEffect(() => { + loadTelemetryStatus(); + }, [loadTelemetryStatus]); + + const handleTelemetryToggle = async (checked: boolean) => { + try { + await upsert(TELEMETRY_CONFIG_KEY, checked, false); + setTelemetryEnabled(checked); + } catch (error) { + console.error('Failed to update telemetry status:', error); + toastService.error({ + title: 'Configuration Error', + msg: 'Failed to update telemetry settings.', + traceback: error instanceof Error ? error.stack || '' : '', + }); + } + }; + + const handleModalClose = () => { + setShowModal(false); + loadTelemetryStatus(); + }; + + if (!TELEMETRY_UI_ENABLED) { + return null; + } + + const title = 'Privacy'; + const description = 'Control how your data is used'; + const toggleLabel = 'Anonymous usage data'; + const toggleDescription = 'Help improve goose by sharing anonymous usage statistics.'; + + const learnMoreLink = ( + + ); + + const toggle = ( + + ); + + const modal = ; + + const toggleRow = ( +
+
+

+ {toggleLabel} +

+

+ {toggleDescription} {learnMoreLink} +

+
+
{toggle}
+
+ ); + + if (isWelcome) { + return ( + <> +
+

{title}

+

{description}

+ {toggleRow} +
+ {modal} + + ); + } + + return ( + <> + + + {title} + {description} + + {toggleRow} + + {modal} + + ); +} diff --git a/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx b/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx index 73bb0c8bcb..a1c3463590 100644 --- a/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx +++ b/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx @@ -92,6 +92,7 @@ export const SwitchModelModal = ({ const [selectedPredefinedModel, setSelectedPredefinedModel] = useState(null); const [predefinedModels, setPredefinedModels] = useState([]); const [loadingModels, setLoadingModels] = useState(false); + const [userClearedModel, setUserClearedModel] = useState(false); // Validate form data const validateForm = useCallback(() => { @@ -265,7 +266,8 @@ export const SwitchModelModal = ({ : []; useEffect(() => { - if (!provider || loadingModels || model || isCustomModel) return; + // Don't auto-select if user explicitly cleared the model + if (!provider || loadingModels || model || isCustomModel || userClearedModel) return; const providerModels = modelOptions .filter((group) => group.options[0]?.provider === provider) @@ -277,7 +279,7 @@ export const SwitchModelModal = ({ setModel(preferredModel); } } - }, [provider, modelOptions, loadingModels, model, isCustomModel]); + }, [provider, modelOptions, loadingModels, model, isCustomModel, userClearedModel]); // Handle model selection change const handleModelChange = (newValue: unknown) => { @@ -285,9 +287,16 @@ export const SwitchModelModal = ({ if (selectedOption?.value === 'custom') { setIsCustomModel(true); setModel(''); + setUserClearedModel(false); + } else if (selectedOption === null) { + // User cleared the selection + setIsCustomModel(false); + setModel(''); + setUserClearedModel(true); } else { setIsCustomModel(false); setModel(selectedOption?.value || ''); + setUserClearedModel(false); } }; @@ -428,6 +437,7 @@ export const SwitchModelModal = ({ setProvider(option?.value || null); setModel(''); setIsCustomModel(false); + setUserClearedModel(false); } }} placeholder="Provider, type to search" @@ -445,26 +455,19 @@ export const SwitchModelModal = ({