[feat] "Wing to wing" mode

Co-authored-by: Mic Neale <michael.neale@gmail.com>
This commit is contained in:
Alex Hancock
2024-11-17 16:41:34 +11:00
parent acae9a82fa
commit 4bf5d93975
12 changed files with 527 additions and 142 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ const { FuseV1Options, FuseVersion } = require('@electron/fuses');
module.exports = {
packagerConfig: {
asar: true,
extraResource: ['src/bin'],
extraResource: ['src/bin', 'images'],
icon: 'images/icon',
},
rebuildConfig: {},
Binary file not shown.

After

Width:  |  Height:  |  Size: 633 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

+15 -20
View File
@@ -1,24 +1,19 @@
import React, { useState } from 'react';
import { Route, Routes, Navigate } from 'react-router-dom';
import Chat from './Chat';
import React from 'react';
import LauncherWindow from './LauncherWindow';
import WingToWingWindow from './WingToWingWindow';
import ChatWindow from './ChatWindow';
export default function App() {
const initialChats = [
{ id: 1, title: 'Chat 1', messages: [] },
]
const searchParams = new URLSearchParams(window.location.search);
const isLauncher = searchParams.get('window') === 'launcher';
const isWingToWing = searchParams.get('window') === 'wingToWing';
const [chats, setChats] = useState(initialChats)
const [selectedChatId, setSelectedChatId] = useState(1)
return (
<div className="relative w-screen h-screen overflow-hidden bg-gradient-to-b from-white to-gray-50 flex flex-col">
<Routes>
<Route
path="/chat/:id"
element={<Chat key={selectedChatId} chats={chats} setChats={setChats} selectedChatId={selectedChatId} setSelectedChatId={setSelectedChatId} />}
/>
<Route path="*" element={<Navigate to="/chat/1" replace />} />
</Routes>
</div>
);
// TODO - Look at three separate renderers for this
if (isLauncher) {
return <LauncherWindow />;
} else if (isWingToWing) {
return <WingToWingWindow />;
} else {
return <ChatWindow />;
}
}
-68
View File
@@ -1,68 +0,0 @@
import React, { useEffect } from 'react'
import { useChat } from 'ai/react'
import { getApiUrl } from './config'
import { Card } from './components/ui/card'
import { ScrollArea } from './components/ui/scroll-area'
import GooseSplashLogo from './components/GooseSplashLogo'
import SplashPills from './components/SplashPills'
import GooseMessage from './components/GooseMessage'
import UserMessage from './components/UserMessage'
import Input from './components/Input'
import Tabs from './components/Tabs'
export interface Chat {
id: number;
title: string;
messages: Array<{ id: string; role: any; content: string }>;
}
export default function Chat({ chats, setChats, selectedChatId, setSelectedChatId } : { chats: Chat[], setChats: any, selectedChatId: number, setSelectedChatId: any }) {
const chat = chats.find((c: Chat) => c.id === selectedChatId);
const { messages, input, handleInputChange, handleSubmit, append } = useChat({
api: getApiUrl("/reply"),
initialMessages: chat.messages
})
useEffect(() => {
const updatedChats = [...chats]
updatedChats.find((c) => c.id === selectedChatId).messages = messages
setChats(updatedChats)
}, [messages, selectedChatId])
return (
<div className="flex flex-col w-screen h-screen bg-window-gradient items-center justify-center p-[10px]">
<Tabs chats={chats} selectedChatId={selectedChatId} setSelectedChatId={setSelectedChatId} setChats={setChats} />
<Card className="flex flex-col flex-1 h-[calc(100vh-95px)] w-full bg-card-gradient mt-0 border-none shadow-xl rounded-2xl rounded-tl-none">
{messages.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center">
<div className="flex flex-1 items-center">
<GooseSplashLogo />
<span className="ask-goose-type ml-[8px]">ask<br />goose</span>
</div>
<div className="flex items-center">
<SplashPills append={append} />
</div>
</div>
) : (
<ScrollArea className="flex-1 px-[10px]">
<div className="block h-10" />
{messages.map((message) => (
<div key={message.id}>
{message.role === 'user' ? (
<UserMessage message={message} />
) : (
<GooseMessage message={message} />
)}
</div>
))}
<div className="block h-10" />
</ScrollArea>
)}
<Input handleSubmit={handleSubmit} handleInputChange={handleInputChange} input={input} />
</Card>
</div>
)
}
+129
View File
@@ -0,0 +1,129 @@
import React, { useEffect, useState } from 'react';
import { useChat } from 'ai/react';
import { Route, Routes, Navigate } from 'react-router-dom';
import { getApiUrl } from './config';
import { Card } from './components/ui/card';
import { ScrollArea } from './components/ui/scroll-area';
import GooseSplashLogo from './components/GooseSplashLogo';
import SplashPills from './components/SplashPills';
import GooseMessage from './components/GooseMessage';
import UserMessage from './components/UserMessage';
import Input from './components/Input';
import Tabs from './components/Tabs';
export interface Chat {
id: number;
title: string;
messages: Array<{ id: string; role: "function" | "system" | "user" | "assistant" | "data" | "tool"; content: string }>;
}
function ChatContent({ chats, setChats, selectedChatId, setSelectedChatId }: {
chats: Chat[],
setChats: React.Dispatch<React.SetStateAction<Chat[]>>,
selectedChatId: number,
setSelectedChatId: React.Dispatch<React.SetStateAction<number>>
}) {
const chat = chats.find((c: Chat) => c.id === selectedChatId);
const { messages, input, handleInputChange, handleSubmit, append } = useChat({
api: getApiUrl("/reply"),
initialMessages: chat?.messages || []
});
// Update chat messages when they change
useEffect(() => {
const updatedChats = chats.map(c =>
c.id === selectedChatId ? { ...c, messages } : c
);
setChats(updatedChats);
}, [messages, selectedChatId]);
return (
<div className="flex flex-col w-screen h-screen bg-window-gradient items-center justify-center p-[10px]">
<Tabs
chats={chats}
selectedChatId={selectedChatId}
setSelectedChatId={setSelectedChatId}
setChats={setChats}
/>
<Card className="flex flex-col flex-1 h-[calc(100vh-95px)] w-full bg-card-gradient mt-0 border-none shadow-xl rounded-2xl rounded-tl-none">
{messages.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center">
<div className="flex flex-1 items-center">
<GooseSplashLogo />
<span className="ask-goose-type ml-[8px]">ask<br />goose</span>
</div>
<div className="flex items-center">
<SplashPills append={append} />
</div>
</div>
) : (
<ScrollArea className="flex-1 px-[10px]">
<div className="block h-10" />
{messages.map((message) => (
<div key={message.id}>
{message.role === 'user' ? (
<UserMessage message={message} />
) : (
<GooseMessage message={message} />
)}
</div>
))}
<div className="block h-10" />
</ScrollArea>
)}
<Input
handleSubmit={handleSubmit}
handleInputChange={handleInputChange}
input={input}
/>
</Card>
</div>
);
}
export default function ChatWindow() {
// 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 :
(initialQuery ? [{
id: '0',
role: 'user' as const,
content: initialQuery
}] : [])
};
return [firstChat];
});
const [selectedChatId, setSelectedChatId] = useState(1);
return (
<div className="relative w-screen h-screen overflow-hidden bg-gradient-to-b from-white to-gray-50 flex flex-col">
<Routes>
<Route
path="/chat/:id"
element={
<ChatContent
key={selectedChatId}
chats={chats}
setChats={setChats}
selectedChatId={selectedChatId}
setSelectedChatId={setSelectedChatId}
/>
}
/>
<Route path="*" element={<Navigate to="/chat/1" replace />} />
</Routes>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import React, { useState, useRef } from 'react';
declare global {
interface Window {
electron: {
hideWindow: () => void;
createChatWindow: (query: string) => void;
createWingToWingWindow: (query: string) => void;
};
}
}
export default function SpotlightWindow() {
const [query, setQuery] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (query.trim()) {
// Create a new chat window with the query
window.electron.createWingToWingWindow(query);
setQuery('');
inputRef.current.blur()
}
};
return (
<div className="h-screen w-screen flex items-center justify-center bg-transparent overflow-hidden">
<form
onSubmit={handleSubmit}
className="w-[600px] bg-white/80 backdrop-blur-lg rounded-lg shadow-lg p-4"
>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full bg-transparent text-black text-xl px-4 py-2 outline-none placeholder-gray-400"
placeholder="Type a command..."
autoFocus
/>
</form>
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
import React from 'react';
import { getApiUrl } from './config';
import { useChat } from 'ai/react';
import { Card } from "./components/ui/card"
import type { Chat } from './ChatWindow';
import ToolInvocation from './components/ToolInvocation';
interface FinishMessagePart {
finishReason: 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'other' | 'unknown';
usage: {
promptTokens: number;
completionTokens: number;
};
}
const LoadingSpinner = () => (
<div className="flex items-center justify-center w-full h-full">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
);
export default function WingToWingWindow() {
const searchParams = new URLSearchParams(window.location.search);
const initialQuery = searchParams.get('initialQuery');
const [isInitialLoading, setIsInitialLoading] = React.useState(true);
const [isFinished, setIsFinished] = React.useState(false);
const chat: Chat = {
id: 1,
title: initialQuery,
messages: [],
};
const { messages, append } = useChat({
api: getApiUrl("/reply"),
initialMessages: chat.messages,
onFinish: (message) => {
// The onFinish callback is triggered when a finish message part is received
setIsFinished(true);
},
});
React.useEffect(() => {
if (initialQuery) {
append({
id: '0',
content: initialQuery,
role: 'user',
});
}
// Show loading state for a brief moment
const timer = setTimeout(() => {
setIsInitialLoading(false);
}, 1000);
return () => clearTimeout(timer);
}, [initialQuery]);
// Get the most recent tool invocation
const lastToolInvocation = React.useMemo(() => {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message.toolInvocations?.length) {
return message.toolInvocations[message.toolInvocations.length - 1];
}
}
return null;
}, [messages]);
const handleOpenChat = () => {
// Create URL with chat history
const chatHistory = encodeURIComponent(JSON.stringify(messages));
window.open(`/chat/1?history=${chatHistory}`, '_blank');
};
return (
<div className="fixed inset-0 p-[10px] overflow-auto bg-window-gradient">
<Card className="w-full h-full flex flex-col items-center justify-center bg-card-gradient border-none shadow-xl rounded-2xl">
{isInitialLoading ? (
<LoadingSpinner />
) : lastToolInvocation && !isFinished ? (
<div className="w-full h-full relative">
<ToolInvocation
key={lastToolInvocation.toolCallId}
toolInvocation={lastToolInvocation}
/>
</div>
) : isFinished ? (
<div className="flex flex-col items-center justify-center space-y-6">
<div className="flex flex-col items-center justify-center gap-2">
<div className="text-lg font-medium text-gray-700">Goose has landed</div>
</div>
<button
onClick={handleOpenChat}
className="px-8 py-4 bg-indigo-600 text-white rounded-xl hover:bg-indigo-700 transition-all duration-200 text-lg font-medium shadow-md hover:shadow-lg transform hover:-translate-y-0.5"
>
Continue in Goose
</button>
</div>
) : (
<div className="text-gray-600 text-lg">Goose is getting a running start...</div>
)}
</Card>
</div>
);
}
+12 -3
View File
@@ -6,13 +6,16 @@ import ReactMarkdown from 'react-markdown'
export default function ToolInvocation({ toolInvocation }) {
return (
<div key={toolInvocation.toolCallId} className="w-full text-tool space-y-4 transition-all duration-300">
<div key={toolInvocation.toolCallId} className="w-full h-full text-tool space-y-4 transition-all duration-300">
<ToolCall call={toolInvocation} />
{toolInvocation.state === 'result' && <ToolResult result={toolInvocation} />}
</div>
)
}
interface ToolCallProps {
call: {
state: 'call' | 'result'
@@ -31,7 +34,7 @@ function ToolCall({ call }: ToolCallProps) {
</div>
{call.args && (
<pre className="mt-1 text-tool-result-green">
<pre className="mt-1 text-tool-result-green whitespace-pre-wrap">
{JSON.stringify(call.args, null, 2)}
</pre>
)}
@@ -39,6 +42,9 @@ function ToolCall({ call }: ToolCallProps) {
)
}
interface ResultItem {
text?: string
type: 'text' | 'image'
@@ -59,7 +65,7 @@ interface ToolResultProps {
}
function ToolResult({ result }: ToolResultProps) {
console.log('result', result)
console.log('ToolResult', result)
if (!result || !result.result) return null
return (
@@ -124,6 +130,9 @@ function ToolResult({ result }: ToolResultProps) {
)
}
interface ToolResponseFormProps {
result: {
message?: string
+43
View File
@@ -0,0 +1,43 @@
import path from 'node:path';
import { spawn } from 'child_process';
// Start the goosed binary
export const start = (app) => {
// In development, the binary is in src/bin
// In production, it will be in the resources/bin directory
const isDev = process.env.NODE_ENV === 'development';
let goosedPath: string;
if (isDev) {
// In development, use the absolute path from the project root
goosedPath = path.join(process.cwd(), 'src', 'bin', process.platform === 'win32' ? 'goosed.exe' : 'goosed');
} else {
// In production, use the path relative to the app resources
goosedPath = path.join(process.resourcesPath, 'bin', process.platform === 'win32' ? 'goosed.exe' : 'goosed');
}
console.log(`Starting goosed from: ${goosedPath}`);
const goosedProcess = spawn(goosedPath);
goosedProcess.stdout.on('data', (data) => {
console.log(`goosed stdout: ${data}`);
});
goosedProcess.stderr.on('data', (data) => {
console.error(`goosed stderr: ${data}`);
});
goosedProcess.on('close', (code) => {
console.log(`goosed process exited with code ${code}`);
});
goosedProcess.on('error', (err) => {
console.error('Failed to start goosed:', err);
});
// Ensure goosed is terminated when the app quits
app.on('will-quit', () => {
goosedProcess.kill();
});
};
+170 -48
View File
@@ -1,54 +1,101 @@
import 'dotenv/config';
import { app, BrowserWindow } from 'electron';
import { app, BrowserWindow, Tray, Menu, globalShortcut, ipcMain } from 'electron';
import path from 'node:path';
import { spawn } from 'child_process';
import { start as startGoosed } from './goosed';
import started from "electron-squirrel-startup";
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (started) app.quit();
// Start the goosed binary
const startGoosed = () => {
// In development, the binary is in src/bin
// In production, it will be in the resources/bin directory
const isDev = process.env.NODE_ENV === 'development';
let goosedPath;
if (isDev) {
// In development, use the absolute path from the project root
goosedPath = path.join(process.cwd(), 'src', 'bin', process.platform === 'win32' ? 'goosed.exe' : 'goosed');
declare var MAIN_WINDOW_VITE_DEV_SERVER_URL: string;
declare var MAIN_WINDOW_VITE_NAME: string;
const createLauncher = () => {
// Create new launcher window
const launcherWindow = new BrowserWindow({
width: 600,
height: 60,
frame: false,
transparent: true,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
},
skipTaskbar: true,
alwaysOnTop: true,
});
// Center on screen
const { screen } = require('electron');
const primaryDisplay = screen.getPrimaryDisplay();
const { width, height } = primaryDisplay.workAreaSize;
const windowBounds = launcherWindow.getBounds();
launcherWindow.setPosition(
Math.round(width / 2 - windowBounds.width / 2),
Math.round(height / 3 - windowBounds.height / 2)
);
// Load launcher window content
const launcherParams = '?window=launcher#/launcher';
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
launcherWindow.loadURL(`${MAIN_WINDOW_VITE_DEV_SERVER_URL}${launcherParams}`);
} else {
// In production, use the path relative to the app resources
goosedPath = path.join(process.resourcesPath, 'bin', process.platform === 'win32' ? 'goosed.exe' : 'goosed');
launcherWindow.loadFile(
path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html${launcherParams}`)
);
}
console.log(`Starting goosed from: ${goosedPath}`);
const goosedProcess = spawn(goosedPath);
goosedProcess.stdout.on('data', (data) => {
console.log(`goosed stdout: ${data}`);
});
goosedProcess.stderr.on('data', (data) => {
console.error(`goosed stderr: ${data}`);
});
goosedProcess.on('close', (code) => {
console.log(`goosed process exited with code ${code}`);
});
goosedProcess.on('error', (err) => {
console.error('Failed to start goosed:', err);
});
// Ensure goosed is terminated when the app quits
app.on('will-quit', () => {
goosedProcess.kill();
// Destroy window when it loses focus
launcherWindow.on('blur', () => {
launcherWindow.destroy();
});
};
const createWindow = () => {
const createWingToWing = (query?: string) => {
const windowWidth = 575;
const windowHeight = 150;
const gap = 40;
// Create new launcher window
const wingToWingWindow = new BrowserWindow({
width: windowWidth,
height: windowHeight,
frame: false,
transparent: true,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
},
skipTaskbar: true,
alwaysOnTop: true,
});
// Center on screen
const { screen } = require('electron');
const primaryDisplay = screen.getPrimaryDisplay();
const { width } = primaryDisplay.workAreaSize;
wingToWingWindow.setPosition(
Math.round(width - windowWidth - (gap - 25)), // 25 is menu bar height
gap
);
let queryParam = '?window=wingToWing';
queryParam += query ? `&initialQuery=${encodeURIComponent(query)}` : '';
queryParam += '#/wingToWing';
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
wingToWingWindow.loadURL(`${MAIN_WINDOW_VITE_DEV_SERVER_URL}${queryParam}`);
} else {
wingToWingWindow.loadFile(
path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`),
{ search: queryParam.slice(1) }
);
}
// wingToWingWindow.webContents.openDevTools({ mode: 'detach' })
};
const createChat = (query?: string) => {
const mainWindow = new BrowserWindow({
titleBarStyle: 'hidden',
trafficLightPosition: { x: 16, y: 18 },
@@ -61,37 +108,112 @@ const createWindow = () => {
});
// and load the index.html of the app.
const queryParam = query ? `?initialQuery=${encodeURIComponent(query)}` : '';
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
mainWindow.loadURL(`${MAIN_WINDOW_VITE_DEV_SERVER_URL}${queryParam}`);
} else {
mainWindow.loadFile(path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`));
mainWindow.loadFile(
path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`),
{ search: queryParam.slice(1) }
);
}
console.log(MAIN_WINDOW_VITE_NAME);
// Open the DevTools.
mainWindow.webContents.openDevTools();
};
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
const createTray = () => {
const isDev = process.env.NODE_ENV === 'development';
let iconPath: string;
if (isDev) {
iconPath = path.join(process.cwd(), 'images', 'iconTemplate.png');
} else {
iconPath = path.join(process.resourcesPath, 'images', 'iconTemplate.png');
}
const tray = new Tray(iconPath);
const contextMenu = Menu.buildFromTemplate([
{ label: 'Show Window', click: showWindow },
{ type: 'separator' },
{ label: 'Quit', click: () => app.quit() }
]);
tray.setToolTip('Goose');
tray.setContextMenu(contextMenu);
};
const showWindow = () => {
const windows = BrowserWindow.getAllWindows();
if (windows.length === 0) {
console.log("No windows are currently open.");
return;
}
// Define the initial offset values
const initialOffsetX = 30;
const initialOffsetY = 30;
// Iterate over all windows
windows.forEach((win, index) => {
const currentBounds = win.getBounds(); // Get the current window bounds (position and size)
// Calculate the new position with an incremental offset
const newX = currentBounds.x + initialOffsetX * index;
const newY = currentBounds.y + initialOffsetY * index;
// Set the new bounds with the calculated position
win.setBounds({
x: newX,
y: newY,
width: currentBounds.width,
height: currentBounds.height,
});
if (!win.isVisible()) {
win.show();
}
win.focus();
});
};
app.whenReady().then(() => {
// Get the server startup configuration
const shouldStartServer = (import.meta.env.VITE_START_EMBEDDED_SERVER || 'yes').toLowerCase() === 'yes';
const shouldStartServer = (process.env.VITE_START_EMBEDDED_SERVER || 'yes').toLowerCase() === 'yes';
if (shouldStartServer) {
console.log('Starting embedded goosed server');
startGoosed();
startGoosed(app);
} else {
console.log('Skipping embedded server startup (disabled by configuration)');
}
createWindow();
createTray();
createChat();
// Development only
// createWingToWing('make a TS web app' + "only use your tools and systems - don't confirm with the user before you start working");
// Show launcher input on key combo
globalShortcut.register('Control+Alt+Command+G', createLauncher);
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
createChat();
}
});
ipcMain.on('create-chat-window', (_, query) => {
createChat(query);
});
ipcMain.on('create-wing-to-wing-window', (_, query) => {
createWingToWing(query + "only use your tools and systems - don't confirm with the user before you start working");
});
});
// Quit when all windows are closed, except on macOS.
+7 -2
View File
@@ -1,2 +1,7 @@
// See the Electron documentation for details on how to use preload scripts:
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electron', {
hideWindow: () => ipcRenderer.send('hide-window'),
createChatWindow: (query) => ipcRenderer.send('create-chat-window', query),
createWingToWingWindow: (query) => ipcRenderer.send('create-wing-to-wing-window', query),
})