feat: goose windows (#880)

Co-authored-by: Ryan Versaw <ryan@versaw.com>
This commit is contained in:
Max Novich
2025-02-10 15:05:13 -08:00
committed by GitHub
parent 98aecbef23
commit cfd3ee8fd9
43 changed files with 1327 additions and 456 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
node_modules
.vite/
out
src/bin/goosed
src/bin/goosed
/src/bin/goosed.exe
+22 -5
View File
@@ -5,6 +5,22 @@ let cfg = {
asar: true,
extraResource: ['src/bin', 'src/images'],
icon: 'src/images/icon',
// Windows specific configuration
win32: {
icon: 'src/images/icon.ico',
certificateFile: process.env.WINDOWS_CERTIFICATE_FILE,
certificatePassword: process.env.WINDOWS_CERTIFICATE_PASSWORD,
rfc3161TimeStampServer: 'http://timestamp.digicert.com',
signWithParams: '/fd sha256 /tr http://timestamp.digicert.com /td sha256'
},
// Protocol registration
protocols: [
{
name: "GooseProtocol",
schemes: ["goose"]
}
],
// macOS specific configuration
osxSign: {
entitlements: 'entitlements.plist',
'entitlements-inherit': 'entitlements.plist',
@@ -34,13 +50,14 @@ module.exports = {
packagerConfig: cfg,
rebuildConfig: {},
makers: [
{
name: '@electron-forge/maker-squirrel',
config: {},
},
{
name: '@electron-forge/maker-zip',
platforms: ['darwin'],
platforms: ['darwin', 'win32'],
config: {
options: {
icon: 'src/images/icon.ico'
}
}
},
{
name: '@electron-forge/maker-deb',
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "goose-app",
"productName": "Goose",
"version": "1.0.5",
"version": "1.0.51",
"description": "Goose App",
"main": ".vite/build/main.js",
"scripts": {
@@ -11,6 +11,7 @@
"package": "electron-forge package",
"make": "electron-forge make",
"bundle:default": "npm run make && cd out/Goose-darwin-arm64 && ditto -c -k --sequesterRsrc --keepParent Goose.app Goose.zip",
"bundle:windows": "npm run make -- --platform=win32 --arch=x64 && node scripts/copy-windows-dlls.js",
"debug": "echo 'run --remote-debugging-port=8315' && lldb out/Goose-darwin-arm64/Goose.app",
"test-e2e": "electron-forge start > /tmp/out.txt & ELECTRON_PID=$! && sleep 12 && if grep -q 'renderer: ChatWindow loaded' /tmp/out.txt; then echo 'process is running'; pkill -f electron; else echo 'not starting correctly'; cat /tmp/out.txt; pkill -f electron; exit 1; fi",
"lint": "eslint \"src/**/*.{ts,tsx}\" --fix",
+84
View File
@@ -0,0 +1,84 @@
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
// Required DLLs that must be present
const REQUIRED_DLLS = [
'libstdc++-6.dll',
'libgcc_s_seh-1.dll',
'libwinpthread-1.dll'
];
// Source and target directories
const sourceDir = path.join(__dirname, '../src/bin');
const targetDir = path.join(__dirname, '../out/Goose-win32-x64/resources/bin');
function ensureDirectoryExists(dir) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
console.log(`Created directory: ${dir}`);
}
}
function copyDLLs() {
// Ensure target directory exists
ensureDirectoryExists(targetDir);
// Get list of DLLs in source directory
const sourceDLLs = fs.readdirSync(sourceDir)
.filter(file => file.toLowerCase().endsWith('.dll'));
console.log('Found DLLs in source directory:', sourceDLLs);
// Check for missing required DLLs
const missingDLLs = REQUIRED_DLLS.filter(dll =>
!sourceDLLs.includes(dll)
);
if (missingDLLs.length > 0) {
console.error('Missing required DLLs:', missingDLLs);
process.exit(1);
}
// Copy all DLLs and the executable to target directory
sourceDLLs.forEach(dll => {
const sourcePath = path.join(sourceDir, dll);
const targetPath = path.join(targetDir, dll);
try {
fs.copyFileSync(sourcePath, targetPath);
console.log(`Copied ${dll} to ${targetDir}`);
} catch (err) {
console.error(`Error copying ${dll}:`, err);
process.exit(1);
}
});
// Copy the executable
const exeName = 'goosed.exe';
const sourceExe = path.join(sourceDir, exeName);
const targetExe = path.join(targetDir, exeName);
try {
if (fs.existsSync(sourceExe)) {
fs.copyFileSync(sourceExe, targetExe);
console.log(`Copied ${exeName} to ${targetDir}`);
} else {
console.error(`${exeName} not found in source directory`);
process.exit(1);
}
} catch (err) {
console.error(`Error copying ${exeName}:`, err);
process.exit(1);
}
console.log('All files copied successfully');
}
// Main execution
try {
copyDLLs();
} catch (err) {
console.error('Error during copy process:', err);
process.exit(1);
}
+85 -96
View File
@@ -14,7 +14,6 @@ import WingToWing, { Working } from './components/WingToWing';
import { askAi } from './utils/askAI';
import { getStoredModel, Provider } from './utils/providerUtils';
import { ChatLayout } from './components/chat_window/ChatLayout';
import { ChatRoutes } from './components/chat_window/ChatRoutes';
import { WelcomeScreen } from './components/welcome_screen/WelcomeScreen';
import { getStoredProvider, initializeSystem } from './utils/providerUtils';
import { useModel } from './components/settings/models/ModelContext';
@@ -22,6 +21,9 @@ import { useRecentModels } from './components/settings/models/RecentModels';
import { createSelectedModel } from './components/settings/models/utils';
import { getDefaultModel } from './components/settings/models/hardcoded_stuff';
import Splash from './components/Splash';
import Settings from './components/settings/Settings';
import MoreModelsSettings from './components/settings/models/MoreModels';
import ConfigureProviders from './components/settings/providers/ConfigureProviders';
export interface Chat {
id: number;
@@ -33,13 +35,19 @@ export interface Chat {
}>;
}
export type View = 'welcome' | 'chat' | 'settings' | 'moreModels' | 'configureProviders';
// This component is our main chat content.
// We'll move the majority of chat logic here, minus the 'view' state.
export function ChatContent({
chats,
setChats,
selectedChatId,
setSelectedChatId,
initialQuery,
setProgressMessage,
setWorking,
setView,
}: {
chats: Chat[];
setChats: React.Dispatch<React.SetStateAction<Chat[]>>;
@@ -48,6 +56,7 @@ export function ChatContent({
initialQuery: string | null;
setProgressMessage: React.Dispatch<React.SetStateAction<string>>;
setWorking: React.Dispatch<React.SetStateAction<Working>>;
setView: (view: View) => void;
}) {
const chat = chats.find((c: Chat) => c.id === selectedChatId);
const [messageMetadata, setMessageMetadata] = useState<Record<string, string[]>>({});
@@ -95,7 +104,6 @@ export function ChatContent({
window.electron.logInfo('last interaction:' + lastInteractionTime);
if (timeSinceLastInteraction > 60000) {
// 60000ms = 1 minute
window.electron.showNotification({
title: 'Goose finished the task.',
body: 'Click here to expand.',
@@ -133,7 +141,7 @@ export function ChatContent({
setLastInteractionTime(Date.now());
append({
role: 'user',
content: content,
content,
});
if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom();
@@ -194,7 +202,8 @@ export function ChatContent({
return (
<div className="flex flex-col w-full h-screen items-center justify-center">
<div className="relative flex items-center h-[36px] w-full bg-bgSubtle border-b border-borderSubtle">
<MoreMenu />
{/* Pass setView to MoreMenu so it can switch to settings or other views */}
<MoreMenu setView={setView} />
</div>
<Card className="flex flex-col flex-1 rounded-none h-[calc(100vh-95px)] w-full bg-bgApp mt-0 border-none relative">
{messages.length === 0 ? (
@@ -215,12 +224,6 @@ export function ChatContent({
)}
</div>
))}
{/* {isLoading && (
<div className="flex items-center justify-center p-4">
<div onClick={() => setShowGame(true)} style={{ cursor: 'pointer' }}>
</div>
</div>
)} */}
{error && (
<div className="flex flex-col items-center justify-center p-4">
<div className="text-red-700 dark:text-red-300 bg-red-400/50 p-3 rounded-lg mb-2">
@@ -258,7 +261,7 @@ export function ChatContent({
isLoading={isLoading}
onStop={onStopGoose}
/>
<BottomMenu hasMessages={hasMessages} />
<BottomMenu hasMessages={hasMessages} setView={setView} />
</div>
</Card>
@@ -268,98 +271,59 @@ export function ChatContent({
}
export default function ChatWindow() {
// We'll add a state controlling which "view" is active.
const [view, setView] = useState<View>('welcome');
// Shared function to create a chat window
const openNewChatWindow = () => {
window.electron.createChatWindow();
};
const { switchModel, currentModel } = useModel(); // Access switchModel via useModel
const { addRecentModel } = useRecentModels(); // Access addRecentModel from useRecentModels
const { switchModel } = useModel();
const { addRecentModel } = useRecentModels();
// Add keyboard shortcut handler
// This will store chat data for the "chat" view.
const [chats, setChats] = useState<Chat[]>(() => [
{
id: 1,
title: 'Chat 1',
messages: [],
},
]);
const [selectedChatId, setSelectedChatId] = useState(1);
// Additional states
const [mode, setMode] = useState<'expanded' | 'compact'>('expanded');
const [working, setWorking] = useState<Working>(Working.Idle);
const [progressMessage, setProgressMessage] = useState<string>('');
const [initialQuery, setInitialQuery] = useState<string | null>(null);
// Keyboard shortcut handler
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Check for Command+N (Mac) or Control+N (Windows/Linux)
if ((event.metaKey || event.ctrlKey) && event.key === 'n') {
event.preventDefault(); // Prevent default browser behavior
event.preventDefault();
openNewChatWindow();
}
};
// Add event listener
window.addEventListener('keydown', handleKeyDown);
// Cleanup
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, []);
// Get initial query and history from URL parameters
const searchParams = new URLSearchParams(window.location.search);
const initialQuery = searchParams.get('initialQuery');
const historyParam = searchParams.get('history');
const initialHistory = historyParam ? JSON.parse(decodeURIComponent(historyParam)) : [];
const [chats, setChats] = useState<Chat[]>(() => {
const firstChat = {
id: 1,
title: initialQuery || 'Chat 1',
messages: initialHistory.length > 0 ? initialHistory : [],
};
return [firstChat];
});
const [selectedChatId, setSelectedChatId] = useState(1);
const [mode, setMode] = useState<'expanded' | 'compact'>(initialQuery ? 'compact' : 'expanded');
const [working, setWorking] = useState<Working>(Working.Idle);
const [progressMessage, setProgressMessage] = useState<string>('');
const [selectedProvider, setSelectedProvider] = useState<string | Provider | null>(null);
const [showWelcomeModal, setShowWelcomeModal] = useState(true);
// Add this useEffect to track changes and update welcome state
const toggleMode = () => {
const newMode = mode === 'expanded' ? 'compact' : 'expanded';
console.log(`Toggle to ${newMode}`);
setMode(newMode);
};
window.electron.logInfo('ChatWindow loaded');
// Fix the handleSubmit function syntax
const handleSubmit = () => {
setShowWelcomeModal(false);
};
// Attempt to detect config for a stored provider
useEffect(() => {
// Check if we already have a provider set
const config = window.electron.getConfig();
const storedProvider = getStoredProvider(config);
if (storedProvider) {
setShowWelcomeModal(false);
setView('chat');
} else {
setShowWelcomeModal(true);
setView('welcome');
}
}, []);
const storeSecret = async (key: string, value: string) => {
const response = await fetch(getApiUrl('/configs/store'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Secret-Key': getSecretKey(),
},
body: JSON.stringify({ key, value }),
});
if (!response.ok) {
throw new Error(`Failed to store secret: ${response.statusText}`);
}
return response;
};
// Initialize system on load if we have a stored provider
// Initialize system if we have a stored provider
useEffect(() => {
const setupStoredProvider = async () => {
const config = window.electron.getConfig();
@@ -378,19 +342,10 @@ export default function ChatWindow() {
await initializeSystem(storedProvider, storedModel);
if (!storedModel) {
// get the default model
const modelName = getDefaultModel(storedProvider.toLowerCase());
// create model object
const model = createSelectedModel(storedProvider.toLowerCase(), modelName);
// Call the context's switchModel to track the set model state in the front end
switchModel(model);
// Keep track of the recently used models
addRecentModel(model);
console.log('set up provider with default model', storedProvider, modelName);
}
} catch (error) {
console.error('Failed to initialize with stored provider:', error);
@@ -401,24 +356,58 @@ export default function ChatWindow() {
setupStoredProvider();
}, []);
// Render WelcomeScreen at root level if showing
if (showWelcomeModal) {
return <WelcomeScreen onSubmit={handleSubmit} />;
}
// Render everything inside ChatLayout now
// We'll switch views inside the ChatLayout children.
// If we want to skip showing ChatLayout for the welcome screen, we can do so.
// But let's do exactly what's requested: put all view options under ChatLayout.
// Only render ChatLayout if not showing welcome screen
return (
<div>
<ChatLayout mode={mode}>
<ChatRoutes
<ChatLayout mode={mode}>
{/* Conditionally render based on `view` */}
{view === 'welcome' && (
<WelcomeScreen
onSubmit={() => {
setView('chat');
}}
/>
)}
{view === 'settings' && (
<Settings
onClose={() => {
setView('chat');
}}
setView={setView}
/>
)}
{view === 'moreModels' && (
<MoreModelsSettings
onClose={() => {
setView('settings');
}}
setView={setView}
/>
)}
{view === 'configureProviders' && (
<ConfigureProviders
onClose={() => {
setView('settings');
}}
setView={setView}
/>
)}
{view === 'chat' && (
<ChatContent
chats={chats}
setChats={setChats}
selectedChatId={selectedChatId}
setSelectedChatId={setSelectedChatId}
initialQuery={initialQuery}
setProgressMessage={setProgressMessage}
setWorking={setWorking}
setView={setView}
/>
</ChatLayout>
</div>
)}
</ChatLayout>
);
}
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
+20
View File
@@ -0,0 +1,20 @@
:: Created by npm, please don't edit manually.
@ECHO OFF
SETLOCAL
SET "NODE_EXE=%~dp0\node.exe"
IF NOT EXIST "%NODE_EXE%" (
SET "NODE_EXE=node"
)
SET "NPM_PREFIX_JS=%~dp0\node_modules\npm\bin\npm-prefix.js"
SET "NPX_CLI_JS=%~dp0\node_modules\npm\bin\npx-cli.js"
FOR /F "delims=" %%F IN ('CALL "%NODE_EXE%" "%NPM_PREFIX_JS%"') DO (
SET "NPM_PREFIX_NPX_CLI_JS=%%F\node_modules\npm\bin\npx-cli.js"
)
IF EXIST "%NPM_PREFIX_NPX_CLI_JS%" (
SET "NPX_CLI_JS=%NPM_PREFIX_NPX_CLI_JS%"
)
"%NODE_EXE%" "%NPX_CLI_JS%" %*
Binary file not shown.
Binary file not shown.
+12 -4
View File
@@ -3,14 +3,21 @@ import { useModel } from './settings/models/ModelContext';
import { useRecentModels } from './settings/models/RecentModels'; // Hook for recent models
import { Sliders } from 'lucide-react';
import { ModelRadioList } from './settings/models/ModelRadioList';
import { useNavigate } from 'react-router-dom';
// Remove react-router-dom usage
// import { useNavigate } from 'react-router-dom';
import { Document, ChevronUp, ChevronDown } from './icons';
import type { View } from '../ChatWindow';
export default function BottomMenu({ hasMessages }) {
export default function BottomMenu({
hasMessages,
setView,
}: {
hasMessages: boolean;
setView?: (view: View) => void;
}) {
const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
const { currentModel } = useModel();
const { recentModels } = useRecentModels(); // Get recent models
const navigate = useNavigate();
const dropdownRef = useRef<HTMLDivElement>(null);
// Add effect to handle clicks outside
@@ -126,7 +133,8 @@ export default function BottomMenu({ hasMessages }) {
border-t border-borderSubtle mt-2"
onClick={() => {
setIsModelMenuOpen(false);
navigate('/settings');
// Instead of navigate('/settings'), call setView('settings').
setView?.('settings');
}}
>
<span className="text-sm">Tools and Settings</span>
+12 -5
View File
@@ -2,18 +2,20 @@ import { Popover, PopoverContent, PopoverTrigger, PopoverPortal } from '@radix-u
import React, { useEffect, useState } from 'react';
import { FaMoon, FaSun } from 'react-icons/fa';
import VertDots from './ui/VertDots';
import { useNavigate } from 'react-router-dom';
// Removed react-router-dom import
// import { useNavigate } from 'react-router-dom';
import { More } from './icons';
import { Settings, Grid, MessageSquare } from 'lucide-react';
import { Button } from './ui/button';
import type { View } from '../../ChatWindow';
interface VersionInfo {
current_version: string;
available_versions: string[];
}
export default function MoreMenu() {
const navigate = useNavigate();
// Accept setView as a prop from the parent (e.g. ChatContent)
export default function MoreMenu({ setView }: { setView?: (view: View) => void }) {
const [open, setOpen] = useState(false);
const [versions, setVersions] = useState<VersionInfo | null>(null);
const [showVersions, setShowVersions] = useState(false);
@@ -229,7 +231,8 @@ export default function MoreMenu() {
<button
onClick={() => {
setOpen(false);
navigate('/settings');
// Instead of navigate('/settings'), call setView to switch.
setView?.('settings');
}}
className="w-full text-left p-2 text-sm hover:bg-bgSubtle transition-colors"
>
@@ -264,12 +267,16 @@ export default function MoreMenu() {
>
Reset Provider
</button>
{/* Provider keys settings */}
{process.env.NODE_ENV === 'development' && (
<button
onClick={() => {
setOpen(false);
navigate('/keys');
// Instead of navigate('/keys'), we might do setView('someKeysView') or open new window.
// For now, just do nothing or set to some placeholder.
// setView?.('keys');
window.electron.createChatWindow();
}}
className="w-full text-left p-2 text-sm hover:bg-bgSubtle transition-colors"
>
@@ -1,38 +0,0 @@
import React from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { ChatContent } from '../../ChatWindow';
import Settings from '../settings/Settings';
import MoreModelsSettings from '../settings/models/MoreModels';
import ConfigureProviders from '../settings/providers/ConfigureProviders';
import { WelcomeScreen } from '../welcome_screen/WelcomeScreen';
export const ChatRoutes = ({
chats,
setChats,
selectedChatId,
setSelectedChatId,
setProgressMessage,
setWorking,
}) => (
<Routes>
<Route
path="/chat/:id"
element={
<ChatContent
chats={chats}
setChats={setChats}
selectedChatId={selectedChatId}
setSelectedChatId={setSelectedChatId}
initialQuery={null}
setProgressMessage={setProgressMessage}
setWorking={setWorking}
/>
}
/>
<Route path="/settings" element={<Settings />} />
<Route path="/settings/more-models" element={<MoreModelsSettings />} />
<Route path="/settings/configure-providers" element={<ConfigureProviders />} />
<Route path="/welcome" element={<WelcomeScreen />} />
<Route path="*" element={<Navigate to="/chat/1" replace />} />
</Routes>
);
+25 -32
View File
@@ -1,6 +1,5 @@
import React, { useState, useEffect } from 'react';
import { ScrollArea } from '../ui/scroll-area';
import { useNavigate, useLocation } from 'react-router-dom';
import { toast } from 'react-toastify';
import { Settings as SettingsType } from './types';
import {
@@ -15,6 +14,7 @@ import { ConfigureBuiltInExtensionModal } from './extensions/ConfigureBuiltInExt
import BackButton from '../ui/BackButton';
import { RecentModelsRadio } from './models/RecentModels';
import { ExtensionItem } from './extensions/ExtensionItem';
import type { View } from '../../ChatWindow';
const EXTENSIONS_DESCRIPTION =
'The Model Context Protocol (MCP) is a system that allows AI models to securely connect with local or remote resources using standard server setups. It works like a client-server setup and expands AI capabilities using three main components: Prompts, Resources, and Tools.';
@@ -46,9 +46,18 @@ const DEFAULT_SETTINGS: SettingsType = {
extensions: BUILT_IN_EXTENSIONS,
};
export default function Settings() {
const navigate = useNavigate();
const location = useLocation();
// We'll accept two props:
// onClose: to go back to chat
// setView: to switch to moreModels, configureProviders, etc.
export default function Settings({
onClose,
setView,
}: {
onClose: () => void;
setView: (view: View) => void;
}) {
// We'll read query params from window.location instead of react-router's useLocation
const [searchParams] = useState(() => new URLSearchParams(window.location.search));
const [settings, setSettings] = React.useState<SettingsType>(() => {
const saved = localStorage.getItem('user_settings');
@@ -96,9 +105,8 @@ export default function Settings() {
// Handle URL parameters for auto-opening extension configuration
useEffect(() => {
const params = new URLSearchParams(location.search);
const extensionId = params.get('extensionId');
const showEnvVars = params.get('showEnvVars');
const extensionId = searchParams.get('extensionId');
const showEnvVars = searchParams.get('showEnvVars');
if (extensionId && showEnvVars === 'true') {
// Find the extension in settings
@@ -113,7 +121,9 @@ export default function Settings() {
}
}
}
}, [location.search, settings.extensions]);
// We only run this once on load
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings.extensions]);
const handleExtensionToggle = async (extensionId: string) => {
// Find the extension to get its current state
@@ -160,28 +170,11 @@ export default function Settings() {
extensions: prev.extensions.filter((ext) => ext.id !== extensionBeingConfigured.id),
}));
setExtensionBeingConfigured(null);
navigate('/settings', { replace: true });
}
};
const handleNavClick = (section: string, e: React.MouseEvent) => {
e.preventDefault();
const scrollArea = document.querySelector('[data-radix-scroll-area-viewport]');
const element = document.getElementById(section.toLowerCase());
if (scrollArea && element) {
const topPos = element.offsetTop;
scrollArea.scrollTo({
top: topPos,
behavior: 'smooth',
});
}
};
const handleExtensionConfigSubmit = () => {
setExtensionBeingConfigured(null);
// Clear the URL parameters after configuration
navigate('/settings', { replace: true });
};
const isBuiltIn = (extensionId: string) => {
@@ -197,7 +190,8 @@ export default function Settings() {
<div className="px-8 pt-6 pb-4">
<BackButton
onClick={() => {
navigate('/chat/1', { replace: true });
// Instead of navigate('/chat/1', { replace: true });
onClose();
}}
/>
<h1 className="text-3xl font-medium text-textStandard mt-1">Settings</h1>
@@ -210,7 +204,10 @@ export default function Settings() {
<div className="flex justify-between items-center mb-6 border-b border-borderSubtle px-8">
<h2 className="text-xl font-medium text-textStandard">Models</h2>
<button
onClick={() => navigate('/settings/more-models')}
onClick={() => {
// Instead of navigate('/settings/more-models'):
setView('moreModels');
}}
className="text-indigo-500 hover:text-indigo-600 text-sm"
>
Browse
@@ -230,7 +227,6 @@ export default function Settings() {
className="text-indigo-500 hover:text-indigo-600 text-sm"
title="Add Manually"
>
{/* <Plus className="h-4 w-4" /> */}
Add
</button>
@@ -255,7 +251,7 @@ export default function Settings() {
<ExtensionItem
key={ext.id}
{...ext}
canConfigure={true} // Ensure gear icon always appears
canConfigure={true}
onToggle={handleExtensionToggle}
onConfigure={(extension) => setExtensionBeingConfigured(extension)}
/>
@@ -273,7 +269,6 @@ export default function Settings() {
isOpen={!!extensionBeingConfigured && isBuiltIn(extensionBeingConfigured.id)}
onClose={() => {
setExtensionBeingConfigured(null);
navigate('/settings', { replace: true });
}}
extension={extensionBeingConfigured}
onSubmit={handleExtensionConfigSubmit}
@@ -283,8 +278,6 @@ export default function Settings() {
isOpen={!!extensionBeingConfigured}
onClose={() => {
setExtensionBeingConfigured(null);
// Clear URL parameters when closing manually
navigate('/settings', { replace: true });
}}
extension={extensionBeingConfigured}
onSubmit={handleExtensionConfigSubmit}
@@ -6,25 +6,27 @@ import BackButton from '../../ui/BackButton';
import { SearchBar } from './Search';
import { useModel } from './ModelContext';
import { AddModelInline } from './AddModelInline';
import { useNavigate } from 'react-router-dom';
// Removed react-router-dom usage
// import { useNavigate } from 'react-router-dom';
import { ScrollArea } from '../../ui/scroll-area';
import type { View } from '../../../ChatWindow';
export default function MoreModelsPage() {
export default function MoreModelsPage({
onClose,
setView,
}: {
onClose: () => void;
setView: (view: View) => void;
}) {
const { currentModel } = useModel();
const navigate = useNavigate();
return (
<div className="h-screen w-full">
<div className="relative flex items-center h-[36px] w-full bg-bgSubtle"></div>
<ScrollArea className="h-full w-full">
{/*
Instead of forcing one row, allow the layout
to stack vertically on small screens:
*/}
<div className="px-8 pt-6 pb-4">
<BackButton />
<BackButton onClick={onClose} />
<h1 className="text-3xl font-medium text-textStandard mt-1">Browse models</h1>
</div>
@@ -34,7 +36,7 @@ export default function MoreModelsPage() {
<div className="flex justify-between items-center mb-6 border-b border-borderSubtle px-8">
<h2 className="text-xl font-medium text-textStandard">Models</h2>
<button
onClick={() => navigate('/settings/configure-providers')}
onClick={() => setView('configureProviders')}
className="text-indigo-500 hover:text-indigo-600 text-sm"
>
Configure
@@ -2,15 +2,22 @@ import React from 'react';
import { ScrollArea } from '../../ui/scroll-area';
import BackButton from '../../ui/BackButton';
import { ConfigureProvidersGrid } from './ConfigureProvidersGrid';
import type { View } from '../../../ChatWindow';
export default function ConfigureProviders() {
export default function ConfigureProviders({
onClose,
setView,
}: {
onClose: () => void;
setView?: (view: View) => void;
}) {
return (
<div className="h-screen w-full">
<div className="relative flex items-center h-[36px] w-full bg-bgSubtle"></div>
<ScrollArea className="h-full w-full">
<div className="px-8 pt-6 pb-4">
<BackButton />
<BackButton onClick={onClose} />
<h1 className="text-3xl font-medium text-textStandard mt-1">Configure</h1>
</div>
+78 -12
View File
@@ -1,6 +1,7 @@
import { spawn } from 'child_process';
import { createServer } from 'net';
import os from 'node:os';
import path from 'node:path';
import { getBinaryPath } from './utils/binaryPath';
import log from './utils/logger';
import { ChildProcessByStdio } from 'node:child_process';
@@ -8,7 +9,7 @@ import { Readable } from 'node:stream';
// Find an available port to start goosed on
export const findAvailablePort = (): Promise<number> => {
return new Promise((resolve, reject) => {
return new Promise((resolve, _reject) => {
const server = createServer();
server.listen(0, '127.0.0.1', () => {
@@ -56,16 +57,18 @@ export const startGoosed = async (
): Promise<[number, string, ChildProcessByStdio<null, Readable, Readable>]> => {
// we default to running goosed in home dir - if not specified
const homeDir = os.homedir();
const isWindows = process.platform === 'win32';
// Ensure dir is properly normalized for the platform
if (!dir) {
dir = homeDir;
}
dir = path.normalize(dir);
// Get the goosed binary path using the shared utility
const goosedPath = getBinaryPath(app, 'goosed');
let goosedPath = getBinaryPath(app, 'goosed');
const port = await findAvailablePort();
// in case we want it
//const isPackaged = app.isPackaged;
log.info(`Starting goosed from: ${goosedPath} on port ${port} in dir ${dir}`);
// Define additional environment variables
@@ -74,12 +77,15 @@ export const startGoosed = async (
HOME: homeDir,
// Set USERPROFILE for Windows
USERPROFILE: homeDir,
// Set APPDATA for Windows
APPDATA: process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'),
// Set LOCAL_APPDATA for Windows
LOCALAPPDATA: process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'),
// Set PATH to include the binary directory
PATH: `${path.dirname(goosedPath)}${path.delimiter}${process.env.PATH}`,
// start with the port specified
GOOSE_PORT: String(port),
GOOSE_SERVER__SECRET_KEY: process.env.GOOSE_SERVER__SECRET_KEY,
// Add any additional environment variables passed in
...env,
};
@@ -87,12 +93,54 @@ export const startGoosed = async (
// Merge parent environment with additional environment variables
const processEnv = { ...process.env, ...additionalEnv };
// Spawn the goosed process with the user's home directory as cwd
const goosedProcess = spawn(goosedPath, ['agent'], {
// Add detailed logging for troubleshooting
log.info(`Process platform: ${process.platform}`);
log.info(`Process cwd: ${process.cwd()}`);
log.info(`Target working directory: ${dir}`);
log.info(`Environment HOME: ${processEnv.HOME}`);
log.info(`Environment USERPROFILE: ${processEnv.USERPROFILE}`);
log.info(`Environment APPDATA: ${processEnv.APPDATA}`);
log.info(`Environment LOCALAPPDATA: ${processEnv.LOCALAPPDATA}`);
log.info(`Environment PATH: ${processEnv.PATH}`);
// Ensure proper executable path on Windows
if (isWindows && !goosedPath.toLowerCase().endsWith('.exe')) {
goosedPath += '.exe';
}
log.info(`Binary path resolved to: ${goosedPath}`);
// Verify binary exists
try {
const fs = require('fs');
const stats = fs.statSync(goosedPath);
log.info(`Binary exists: ${stats.isFile()}`);
} catch (error) {
log.error(`Binary not found at ${goosedPath}:`, error);
throw new Error(`Binary not found at ${goosedPath}`);
}
const spawnOptions = {
cwd: dir,
env: processEnv,
stdio: ['ignore', 'pipe', 'pipe'],
});
// Hide terminal window on Windows
windowsHide: true,
// Run detached on Windows only to avoid terminal windows
detached: isWindows,
// Never use shell to avoid terminal windows
shell: false,
};
// Log spawn options for debugging
log.info('Spawn options:', JSON.stringify(spawnOptions, null, 2));
// Spawn the goosed process
const goosedProcess = spawn(goosedPath, ['agent'], spawnOptions);
// Only unref on Windows to allow it to run independently of the parent
if (isWindows) {
goosedProcess.unref();
}
goosedProcess.stdout.on('data', (data) => {
log.info(`goosed stdout for port ${port} and dir ${dir}: ${data.toString()}`);
@@ -116,7 +164,16 @@ export const startGoosed = async (
log.info(`Goosed isReady ${isReady}`);
if (!isReady) {
log.error(`Goosed server failed to start on port ${port}`);
goosedProcess.kill();
try {
if (isWindows) {
// On Windows, use taskkill to forcefully terminate the process tree
spawn('taskkill', ['/pid', goosedProcess.pid.toString(), '/T', '/F']);
} else {
goosedProcess.kill();
}
} catch (error) {
log.error('Error while terminating goosed process:', error);
}
throw new Error(`Goosed server failed to start on port ${port}`);
}
@@ -124,7 +181,16 @@ export const startGoosed = async (
// TODO will need to do it at tab level next
app.on('will-quit', () => {
log.info('App quitting, terminating goosed server');
goosedProcess.kill();
try {
if (isWindows) {
// On Windows, use taskkill to forcefully terminate the process tree
spawn('taskkill', ['/pid', goosedProcess.pid.toString(), '/T', '/F']);
} else {
goosedProcess.kill();
}
} catch (error) {
log.error('Error while terminating goosed process:', error);
}
});
log.info(`Goosed server successfully started on port ${port}`);
+105 -22
View File
@@ -14,6 +14,7 @@ import {
} from 'electron';
import started from 'electron-squirrel-startup';
import path from 'node:path';
import { handleSquirrelEvent } from './setup-events';
import { startGoosed } from './goosed';
import { getBinaryPath } from './utils/binaryPath';
import { loadShellEnv } from './utils/loadEnv';
@@ -26,25 +27,112 @@ import {
saveSettings,
updateEnvironmentVariables,
} from './utils/settings';
const { exec } = require('child_process');
import * as crypto from 'crypto';
import * as electron from 'electron';
import { exec as execCallback } from 'child_process';
import { promisify } from 'util';
const exec = promisify(execCallback);
// Handle Squirrel events for Windows installer
if (process.platform === 'win32') {
console.log('Windows detected, command line args:', process.argv);
if (handleSquirrelEvent()) {
// squirrel event handled and app will exit in 1000ms, so don't do anything else
process.exit(0);
}
// Handle the protocol on Windows during first launch
if (process.argv.length >= 2) {
const url = process.argv[1];
console.log('Checking URL from command line:', url);
if (url.startsWith('goose://')) {
console.log('Found goose:// URL in command line args');
app.emit('open-url', { preventDefault: () => {} }, url);
}
}
}
// Ensure single instance lock
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
app.on('second-instance', (event, commandLine, _workingDirectory) => {
// Someone tried to run a second instance
console.log('Second instance detected with args:', commandLine);
// Get existing window or create new one
const existingWindows = BrowserWindow.getAllWindows();
if (existingWindows.length > 0) {
const window = existingWindows[0];
if (window.isMinimized()) window.restore();
window.focus();
if (process.platform === 'win32') {
// Protocol handling for Windows
const url = commandLine[commandLine.length - 1];
console.log('Checking last arg for protocol:', url);
if (url.startsWith('goose://')) {
console.log('Found goose:// URL in second instance');
// Send the URL to the window
if (!window.webContents.isLoading()) {
window.webContents.send('add-extension', url);
} else {
window.webContents.once('did-finish-load', () => {
window.webContents.send('add-extension', url);
});
}
}
}
}
});
}
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (started) app.quit();
// Register protocol handler
if (process.platform === 'win32') {
const success = app.setAsDefaultProtocolClient('goose', process.execPath, ['--']);
console.log('Registering protocol handler for Windows:', success ? 'success' : 'failed');
} else {
const success = app.setAsDefaultProtocolClient('goose');
console.log('Registering protocol handler:', success ? 'success' : 'failed');
}
// Log if we're the default protocol handler
console.log('Is default protocol handler:', app.isDefaultProtocolClient('goose'));
// Triggered when the user opens "goose://..." links
app.on('open-url', async (event, url) => {
event.preventDefault();
console.log('open-url:', url);
const recentDirs = loadRecentDirs();
const openDir = recentDirs.length > 0 ? recentDirs[0] : null;
// Get existing window or create new one
let targetWindow: BrowserWindow;
const existingWindows = BrowserWindow.getAllWindows();
// Create the new Chat window
const newWindow = await createChat(app, undefined, openDir);
if (existingWindows.length > 0) {
targetWindow = existingWindows[0];
if (targetWindow.isMinimized()) targetWindow.restore();
targetWindow.focus();
} else {
const recentDirs = loadRecentDirs();
const openDir = recentDirs.length > 0 ? recentDirs[0] : null;
targetWindow = await createChat(app, undefined, openDir);
}
newWindow.webContents.once('did-finish-load', () => {
newWindow.webContents.send('add-extension', url);
});
// Wait for window to be ready before sending the extension URL
if (!targetWindow.webContents.isLoading()) {
targetWindow.webContents.send('add-extension', url);
} else {
targetWindow.webContents.once('did-finish-load', () => {
targetWindow.webContents.send('add-extension', url);
});
}
});
declare var MAIN_WINDOW_VITE_DEV_SERVER_URL: string;
@@ -77,8 +165,7 @@ const getGooseProvider = () => {
};
const generateSecretKey = () => {
const crypto = require('crypto');
let key = crypto.randomBytes(32).toString('hex');
const key = crypto.randomBytes(32).toString('hex');
process.env.GOOSE_SERVER__SECRET_KEY = key;
return key;
};
@@ -98,7 +185,7 @@ const createLauncher = () => {
const launcherWindow = new BrowserWindow({
width: 600,
height: 60,
frame: false,
frame: process.platform === 'darwin' ? false : true,
transparent: false,
webPreferences: {
preload: path.join(__dirname, 'preload.ts'),
@@ -110,8 +197,7 @@ const createLauncher = () => {
});
// Center on screen
const { screen } = require('electron');
const primaryDisplay = screen.getPrimaryDisplay();
const primaryDisplay = electron.screen.getPrimaryDisplay();
const { width, height } = primaryDisplay.workAreaSize;
const windowBounds = launcherWindow.getBounds();
@@ -141,18 +227,16 @@ let windowCounter = 0;
const windowMap = new Map<number, BrowserWindow>();
const createChat = async (app, query?: string, dir?: string, version?: string) => {
const env = version ? { GOOSE_AGENT_VERSION: version } : {};
// Apply current environment settings before creating chat
updateEnvironmentVariables(envToggles);
const [port, working_dir, goosedProcess] = await startGoosed(app, dir);
const mainWindow = new BrowserWindow({
titleBarStyle: 'hidden',
trafficLightPosition: { x: 16, y: 10 },
vibrancy: 'window',
frame: false,
titleBarStyle: process.platform === 'darwin' ? 'hidden' : 'default',
trafficLightPosition: process.platform === 'darwin' ? { x: 16, y: 10 } : undefined,
vibrancy: process.platform === 'darwin' ? 'window' : undefined,
frame: process.platform === 'darwin' ? false : true,
width: 750,
height: 800,
minWidth: 650,
@@ -186,8 +270,7 @@ const createChat = async (app, query?: string, dir?: string, version?: string) =
// Load the index.html of the app.
const queryParam = query ? `?initialQuery=${encodeURIComponent(query)}` : '';
const { screen } = require('electron');
const primaryDisplay = screen.getPrimaryDisplay();
const primaryDisplay = electron.screen.getPrimaryDisplay();
const { width } = primaryDisplay.workAreaSize;
// Increment window counter to track number of windows
@@ -357,7 +440,7 @@ ipcMain.handle('select-file-or-directory', async () => {
ipcMain.handle('check-ollama', async () => {
try {
return new Promise((resolve, reject) => {
return new Promise((resolve) => {
// Run `ps` and filter for "ollama"
exec('ps aux | grep -iw "[o]llama"', (error, stdout, stderr) => {
if (error) {
+29
View File
@@ -0,0 +1,29 @@
const { contextBridge, ipcRenderer } = require('electron')
const config = JSON.parse(process.argv.find((arg) => arg.startsWith('{')) || '{}');
contextBridge.exposeInMainWorld('appConfig', {
get: (key) => config[key],
getAll: () => config,
});
contextBridge.exposeInMainWorld('electron', {
getConfig: () => config,
hideWindow: () => ipcRenderer.send('hide-window'),
directoryChooser: (replace) => ipcRenderer.send('directory-chooser', replace),
createChatWindow: (query, dir, version) => ipcRenderer.send('create-chat-window', query, dir, version),
logInfo: (txt) => ipcRenderer.send('logInfo', txt),
showNotification: (data) => ipcRenderer.send('notify', data),
createWingToWingWindow: (query) => ipcRenderer.send('create-wing-to-wing-window', query),
openInChrome: (url) => ipcRenderer.send('open-in-chrome', url),
fetchMetadata: (url) => ipcRenderer.invoke('fetch-metadata', url),
reloadApp: () => ipcRenderer.send('reload-app'),
checkForOllama: () => ipcRenderer.invoke('check-ollama'),
selectFileOrDirectory: () => ipcRenderer.invoke('select-file-or-directory'),
startPowerSaveBlocker: () => ipcRenderer.invoke('start-power-save-blocker'),
stopPowerSaveBlocker: () => ipcRenderer.invoke('stop-power-save-blocker'),
getBinaryPath: (binaryName) => ipcRenderer.invoke('get-binary-path', binaryName),
on: (channel, callback) => ipcRenderer.on(channel, callback),
off: (channel, callback) => ipcRenderer.off(channel, callback),
send: (key) => ipcRenderer.send(key)
});
+71
View File
@@ -0,0 +1,71 @@
import { app } from 'electron';
import * as path from 'path';
import { spawn as spawnProcess } from 'child_process';
import * as fs from 'fs';
export function handleSquirrelEvent(): boolean {
if (process.argv.length === 1) {
return false;
}
const appFolder = path.resolve(process.execPath, '..');
const rootAtomFolder = path.resolve(appFolder, '..');
const updateDotExe = path.resolve(path.join(rootAtomFolder, 'Update.exe'));
const exeName = path.basename(process.execPath);
const spawnUpdate = function (args: string[]) {
try {
return spawnProcess(updateDotExe, args, { detached: true });
} catch (error) {
console.error('Failed to spawn update process:', error);
return null;
}
};
const squirrelEvent = process.argv[1];
switch (squirrelEvent) {
case '--squirrel-install':
case '--squirrel-updated': {
// Register protocol handler
spawnUpdate(['--createShortcut', exeName]);
// Register protocol
const regCommand = `Windows Registry Editor Version 5.00
[HKEY_CLASSES_ROOT\\goose]
@="URL:Goose Protocol"
"URL Protocol"=""
[HKEY_CLASSES_ROOT\\goose\\DefaultIcon]
@="\\"${process.execPath.replace(/\\/g, '\\\\')},1\\""
[HKEY_CLASSES_ROOT\\goose\\shell]
[HKEY_CLASSES_ROOT\\goose\\shell\\open]
[HKEY_CLASSES_ROOT\\goose\\shell\\open\\command]
@="\\"${process.execPath.replace(/\\/g, '\\\\')}\\" \\"%1\\""`;
fs.writeFileSync('goose-protocol.reg', regCommand);
spawnProcess('regedit.exe', ['/s', 'goose-protocol.reg']);
setTimeout(() => app.quit(), 1000);
return true;
}
case '--squirrel-uninstall': {
// Remove protocol handler
spawnUpdate(['--removeShortcut', exeName]);
setTimeout(() => app.quit(), 1000);
return true;
}
case '--squirrel-obsolete': {
app.quit();
return true;
}
default:
return false;
}
}
+42 -11
View File
@@ -1,24 +1,55 @@
import path from 'node:path';
import fs from 'node:fs';
import Electron from 'electron';
import log from './logger';
export const getBinaryPath = (app: Electron.App, binaryName: string): string => {
const isDev = process.env.NODE_ENV === 'development';
const isPackaged = app.isPackaged;
const isWindows = process.platform === 'win32';
// On Windows, use .cmd for npx and .exe for uvx
const executableName = isWindows
? binaryName === 'npx'
? 'npx.cmd'
: `${binaryName}.exe`
: binaryName;
// List of possible paths to check
const possiblePaths = [];
if (isDev && !isPackaged) {
// In development, use the absolute path from the project root
return path.join(
process.cwd(),
'src',
'bin',
process.platform === 'win32' ? `${binaryName}.exe` : binaryName
// In development, check multiple possible locations
possiblePaths.push(
path.join(process.cwd(), 'src', 'bin', executableName),
path.join(process.cwd(), 'bin', executableName),
path.join(process.cwd(), '..', '..', 'target', 'release', executableName)
);
} else {
// In production, use the path relative to the app resources
return path.join(
process.resourcesPath,
'bin',
process.platform === 'win32' ? `${binaryName}.exe` : binaryName
// In production, check resources paths
possiblePaths.push(
path.join(process.resourcesPath, 'bin', executableName),
path.join(app.getAppPath(), 'resources', 'bin', executableName)
);
}
// Log all paths we're checking
log.info('Checking binary paths:', possiblePaths);
// Try each path and return the first one that exists
for (const binPath of possiblePaths) {
try {
if (fs.existsSync(binPath)) {
log.info(`Found binary at: ${binPath}`);
return binPath;
}
} catch (error) {
log.error(`Error checking path ${binPath}:`, error);
}
}
// If we get here, we couldn't find the binary
const error = `Could not find ${binaryName} binary in any of the expected locations: ${possiblePaths.join(', ')}`;
log.error(error);
throw new Error(error);
};