diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json index b3e4b6ee3..bf065167d 100644 --- a/ui/desktop/package-lock.json +++ b/ui/desktop/package-lock.json @@ -36,7 +36,8 @@ "react-router-dom": "^6.28.0", "react-syntax-highlighter": "^15.6.1", "tailwind-merge": "^2.5.4", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "uuid": "^11.0.3" }, "devDependencies": { "@electron-forge/cli": "^7.5.0", @@ -11961,6 +11962,19 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz", + "integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", diff --git a/ui/desktop/package.json b/ui/desktop/package.json index da0999b83..63e19e05b 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -64,6 +64,7 @@ "react-router-dom": "^6.28.0", "react-syntax-highlighter": "^15.6.1", "tailwind-merge": "^2.5.4", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "uuid": "^11.0.3" } } diff --git a/ui/desktop/src/ChatWindow.tsx b/ui/desktop/src/ChatWindow.tsx index 82b838ff7..9b95b484a 100644 --- a/ui/desktop/src/ChatWindow.tsx +++ b/ui/desktop/src/ChatWindow.tsx @@ -1,16 +1,18 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { Message, useChat } from './ai-sdk-fork/useChat'; -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 React, {useEffect, useRef, useState} from 'react'; +import {Message,useChat} from './ai-sdk-fork/useChat'; +import {Navigate, Route, Routes} from 'react-router-dom'; +import {getApiUrl} from './config'; +import {Card} from './components/ui/card'; +import {ScrollArea} from './components/ui/scroll-area'; import Splash from './components/Splash'; import GooseMessage from './components/GooseMessage'; import UserMessage from './components/UserMessage'; import Input from './components/Input'; import MoreMenu from './components/MoreMenu'; +import {Bird} from './components/ui/icons'; import LoadingGoose from './components/LoadingGoose'; -import { ApiKeyWarning } from './components/ApiKeyWarning'; +import {ApiKeyWarning} from './components/ApiKeyWarning'; + import { askAi, getPromptTemplates } from './utils/askAI'; import WingToWing, { Working } from './components/WingToWing'; @@ -89,8 +91,23 @@ function ChatContent({ c.id === selectedChatId ? { ...c, messages } : c ); setChats(updatedChats); + const currentChat = chats.find(chat => chat.id === selectedChatId); + if (currentChat) { + const sessionToSave = { + messages: currentChat.messages, + directory: window.appConfig.get("GOOSE_WORKING_DIR") + }; + saveSession(sessionToSave); + } }, [messages, selectedChatId]); + // Function to save a session + const saveSession = (session) => { + if(session.messages === undefined || session.messages.length === 0) return + window.electron.saveSession(session); + }; + + const initialQueryAppended = useRef(false); useEffect(() => { if (initialQuery && !initialQueryAppended.current) { @@ -149,11 +166,11 @@ function ChatContent({ } }), }; - + const updatedMessages = [...messages.slice(0, -1), newLastMessage]; setMessages(updatedMessages); } - + }; return ( @@ -225,6 +242,24 @@ export default function ChatWindow() { window.electron.createChatWindow(); }; + // Function to get a session by ID + const getSession = async (sessionId) => { + try { + return window.electron.getSession(sessionId); + } catch (error) { + console.error('Failed to load session:', error); + } + }; + + const convertSessionToChat = (session) => { + const chat = { + id: 1, + title: session.name, + messages: session.messages, + }; + return chat; + } + // Add keyboard shortcut handler useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -259,6 +294,12 @@ export default function ChatWindow() { title: initialQuery || 'Chat 1', messages: initialHistory.length > 0 ? initialHistory : [], }; + const sessionId = window.appConfig.get("GOOSE_SESSION_ID"); + getSession(sessionId).then((session) => { + if (session) { + setChats([convertSessionToChat(session)]); + } + }); return [firstChat]; }); @@ -308,11 +349,11 @@ export default function ChatWindow() { } /> - + - + )} ); -} \ No newline at end of file +} diff --git a/ui/desktop/src/LauncherWindow.tsx b/ui/desktop/src/LauncherWindow.tsx index ce1955ced..fb7a48d95 100644 --- a/ui/desktop/src/LauncherWindow.tsx +++ b/ui/desktop/src/LauncherWindow.tsx @@ -3,6 +3,10 @@ import React, { useState, useRef } from 'react'; declare global { interface Window { electron: { + getConfig(): object; + getSession(arg0: string): object; + logInfo(arg0: string): object; + saveSession(arg0: { name: string; messages: Array; directory: string }): object; hideWindow: () => void; createChatWindow: (query: string) => void; }; @@ -41,4 +45,4 @@ export default function SpotlightWindow() { ); -} \ No newline at end of file +} diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 198daab86..438104548 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -7,6 +7,7 @@ import started from "electron-squirrel-startup"; import log from './utils/logger'; import { exec } from 'child_process'; import { addRecentDir, loadRecentDirs } from './utils/recentDirs'; +import { loadSessions, saveSession, clearAllSessions } from './utils/sessionManager'; // Handle creating/removing shortcuts on Windows when installing/uninstalling. if (started) app.quit(); @@ -87,7 +88,7 @@ const createLauncher = () => { let windowCounter = 0; const windowMap = new Map(); -const createChat = async (app, query?: string, dir?: string) => { +const createChat = async (app, query?: string, dir?: string, sessionId?: string) => { const [port, working_dir] = await startGoosed(app, dir); const mainWindow = new BrowserWindow({ @@ -102,7 +103,7 @@ const createChat = async (app, query?: string, dir?: string) => { icon: path.join(__dirname, '../images/icon'), webPreferences: { preload: path.join(__dirname, 'preload.js'), - additionalArguments: [JSON.stringify({ ...appConfig, GOOSE_SERVER__PORT: port, GOOSE_WORKING_DIR: working_dir })], + additionalArguments: [JSON.stringify({ ...appConfig, GOOSE_SERVER__PORT: port, GOOSE_WORKING_DIR: working_dir, GOOSE_SESSION_ID: sessionId })], }, }); @@ -211,6 +212,17 @@ const buildRecentFilesMenu = () => { })); }; +// Add Recent Sessions submenu +const buildRecentSessionsMenu = () => { + const sessions = loadSessions(); + return sessions.map(session => ({ + label: session.name, + click: () => { + createChat(app, undefined, session.directory, session.name); + } + })); +}; + const openDirectoryDialog = async () => { const result = await dialog.showOpenDialog({ properties: ['openDirectory'] @@ -244,6 +256,23 @@ app.whenReady().then(async () => { }, })); + const recentSessionsSubmenu = buildRecentSessionsMenu(); + if (recentSessionsSubmenu.length > 0) { + fileMenu.submenu.append(new MenuItem({ type: 'separator' })); + fileMenu.submenu.append(new MenuItem({ + label: 'Recent Sessions', + submenu: recentSessionsSubmenu + })); + } + + // Add option to clear session history + fileMenu.submenu.append(new MenuItem({ + label: 'Clear Session History', + click() { + clearAllSessions(); + }, + })); + // Add Recent Files submenu const recentFilesSubmenu = buildRecentFilesMenu(); if (recentFilesSubmenu.length > 0) { @@ -274,8 +303,41 @@ app.whenReady().then(async () => { } }); + ipcMain.on('save-session', (_, session) => { + try { + return saveSession(session); + } catch (error) { + console.error('Failed to save session:', error); + throw error; + } +}); + + ipcMain.on('get-session', (_, sessionId) => { + try { + return loadSessions().find(session => session.name === sessionId); + } catch (error) { + console.error('Failed to load sessions:', error); + throw error; + } + }); + ipcMain.on('create-chat-window', (_, query) => { - createChat(app, query); + createChat(app, query); + }); + + ipcMain.on('clear-session-history', () => { + // Clear all stored session data + try { + // We'll simulate clearing session data - implement this using your session storage logic + clearAllSessions(); + console.log('All session history cleared'); + // Optionally notify all open chat windows to update/reset + windowMap.forEach(win => { + win.webContents.send('session-history-cleared'); + }); + } catch (error) { + console.error('Failed to clear session history:', error); + } }); ipcMain.on('directory-chooser', (_) => { @@ -330,4 +392,4 @@ app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit(); } -}); \ No newline at end of file +}); diff --git a/ui/desktop/src/preload.js b/ui/desktop/src/preload.js index ccd02f0b4..1d71dbde3 100644 --- a/ui/desktop/src/preload.js +++ b/ui/desktop/src/preload.js @@ -16,6 +16,8 @@ contextBridge.exposeInMainWorld('electron', { logInfo: (txt) => ipcRenderer.send('logInfo', txt), showNotification: (data) => ipcRenderer.send('notify', data), createWingToWingWindow: (query) => ipcRenderer.send('create-wing-to-wing-window', query), + saveSession: (session) => ipcRenderer.send('save-session', session), + getSession: (sessionId) => ipcRenderer.send('get-session', sessionId), openInChrome: (url) => ipcRenderer.send('open-in-chrome', url), fetchMetadata: (url) => ipcRenderer.invoke('fetch-metadata', url), }) diff --git a/ui/desktop/src/types/electron.d.ts b/ui/desktop/src/types/electron.d.ts index 8c0b0bb8c..d3bcec4d0 100644 --- a/ui/desktop/src/types/electron.d.ts +++ b/ui/desktop/src/types/electron.d.ts @@ -6,10 +6,12 @@ interface IElectronAPI { GOOSE_API_HOST: string; apiCredsMissing: boolean; }; + getSession: (sessionId: string) => object; + saveSession: (session: { name: string; messages: Array; directory: string }) => string; } declare global { interface Window { electron: IElectronAPI; } -} \ No newline at end of file +} diff --git a/ui/desktop/src/utils/sessionManager.ts b/ui/desktop/src/utils/sessionManager.ts new file mode 100644 index 000000000..03687e8eb --- /dev/null +++ b/ui/desktop/src/utils/sessionManager.ts @@ -0,0 +1,68 @@ +import fs from 'fs'; +import path from 'path'; +import { app } from 'electron'; + +const SESSIONS_PATH = path.join(app.getPath('userData'), 'sessions'); +if (!fs.existsSync(SESSIONS_PATH)) { + fs.mkdirSync(SESSIONS_PATH); +} + +interface Session { + name: string; // Derived from a synopsis of the conversation + messages: Array<{ + id: number; + role: 'function' | 'system' | 'user' | 'assistant' | 'data' | 'tool'; + content: string; + }>; + directory: string; +} + +function generateSessionName(messages: object[]): string { + // Create a session name based on the first message or a combination of initial messages + if (messages === undefined || messages.length === 0) return 'empty_session'; + return messages[0].content.split(' ').slice(0, 5).join(' '); +} + +export function saveSession(session: Session): string { + try { + const sessionData = { + ...session, + name: generateSessionName(session.messages) + }; + const filePath = path.join(SESSIONS_PATH, `${sessionData.name}.json`); + fs.writeFileSync(filePath, JSON.stringify(sessionData, null, 2)); + console.log('Session saved:', sessionData); + return sessionData.name; + } catch (error) { + console.error('Error saving session:', error); + } +} + +export function loadSessions(): Session[] { + try { + console.log('Attempting to load sessions from:', SESSIONS_PATH); + const files = fs.readdirSync(SESSIONS_PATH); + if (files.length === 0) { + console.warn('No session files found in directory'); + } else { + console.log('Session files found:', files); + } + return files.map(file => { + const data = fs.readFileSync(path.join(SESSIONS_PATH, file), 'utf8'); + return JSON.parse(data) as Session; + }); + } catch (error) { + console.error('Error loading sessions:', error); + return []; + } +} + +export function clearAllSessions(): void { + try { + const files = fs.readdirSync(SESSIONS_PATH); + files.forEach(file => fs.unlinkSync(path.join(SESSIONS_PATH, file))); + console.log('All sessions cleared'); + } catch (error) { + console.error('Error clearing sessions:', error); + } +}