mirror of
https://github.com/aljazceru/goose.git
synced 2025-12-24 01:24:28 +01:00
Global/local chat history store in localstorage (#2428)
This commit is contained in:
@@ -30,8 +30,7 @@
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,css,json}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,css,json}\"",
|
||||
"prepare": "cd ../.. && husky install",
|
||||
"start-alpha-gui": "ALPHA=true npm run start-gui",
|
||||
"start-alpha-server": "cd ../.. && just run-ui-alpha"
|
||||
"start-alpha-gui": "ALPHA=true npm run start-gui"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "^7.5.0",
|
||||
|
||||
@@ -5,19 +5,20 @@ import Stop from './ui/Stop';
|
||||
import { Attach, Send } from './icons';
|
||||
import { debounce } from 'lodash';
|
||||
import BottomMenu from './bottom_menu/BottomMenu';
|
||||
import { LocalMessageStorage } from '../utils/localMessageStorage';
|
||||
|
||||
interface InputProps {
|
||||
interface ChatInputProps {
|
||||
handleSubmit: (e: React.FormEvent) => void;
|
||||
isLoading?: boolean;
|
||||
onStop?: () => void;
|
||||
commandHistory?: string[];
|
||||
commandHistory?: string[]; // Current chat's message history
|
||||
initialValue?: string;
|
||||
droppedFiles?: string[];
|
||||
setView: (view: View) => void;
|
||||
numTokens?: number;
|
||||
}
|
||||
|
||||
export default function Input({
|
||||
export default function ChatInput({
|
||||
handleSubmit,
|
||||
isLoading = false,
|
||||
onStop,
|
||||
@@ -26,23 +27,25 @@ export default function Input({
|
||||
setView,
|
||||
numTokens,
|
||||
droppedFiles = [],
|
||||
}: InputProps) {
|
||||
}: ChatInputProps) {
|
||||
const [_value, setValue] = useState(initialValue);
|
||||
const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
// Update internal value when initialValue changes
|
||||
useEffect(() => {
|
||||
if (initialValue) {
|
||||
setValue(initialValue);
|
||||
setDisplayValue(initialValue);
|
||||
}
|
||||
// Reset history index when input is cleared
|
||||
setHistoryIndex(-1);
|
||||
setIsInGlobalHistory(false);
|
||||
}, [initialValue]);
|
||||
|
||||
// State to track if the IME is composing (i.e., in the middle of Japanese IME input)
|
||||
const [isComposing, setIsComposing] = useState(false);
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
const [savedInput, setSavedInput] = useState('');
|
||||
const [isInGlobalHistory, setIsInGlobalHistory] = useState(false);
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [processedFilePaths, setProcessedFilePaths] = useState<string[]>([]);
|
||||
|
||||
@@ -56,17 +59,20 @@ export default function Input({
|
||||
const maxHeight = 10 * 24;
|
||||
|
||||
// If we have dropped files, add them to the input and update our state.
|
||||
if (processedFilePaths !== droppedFiles) {
|
||||
useEffect(() => {
|
||||
if (processedFilePaths !== droppedFiles && droppedFiles.length > 0) {
|
||||
// Append file paths that aren't in displayValue.
|
||||
let joinedPaths =
|
||||
displayValue.trim() +
|
||||
' ' +
|
||||
droppedFiles.filter((path) => !displayValue.includes(path)).join(' ');
|
||||
const currentText = displayValue || '';
|
||||
const joinedPaths = currentText.trim()
|
||||
? `${currentText.trim()} ${droppedFiles.filter((path) => !currentText.includes(path)).join(' ')}`
|
||||
: droppedFiles.join(' ');
|
||||
|
||||
setDisplayValue(joinedPaths);
|
||||
setValue(joinedPaths);
|
||||
textAreaRef.current?.focus();
|
||||
setProcessedFilePaths(droppedFiles);
|
||||
}
|
||||
}, [droppedFiles, processedFilePaths, displayValue]);
|
||||
|
||||
// Debounced function to update actual value
|
||||
const debouncedSetValue = useCallback((val: string) => {
|
||||
@@ -117,49 +123,77 @@ export default function Input({
|
||||
};
|
||||
|
||||
const handleHistoryNavigation = (evt: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
evt.preventDefault();
|
||||
const isUp = evt.key === 'ArrowUp';
|
||||
const isDown = evt.key === 'ArrowDown';
|
||||
|
||||
// Save current input if we're just starting to navigate history
|
||||
if (historyIndex === -1) {
|
||||
setSavedInput(displayValue);
|
||||
}
|
||||
|
||||
// Calculate new history index
|
||||
let newIndex = historyIndex;
|
||||
if (evt.key === 'ArrowUp') {
|
||||
// Move backwards through history
|
||||
if (historyIndex < commandHistory.length - 1) {
|
||||
newIndex = historyIndex + 1;
|
||||
}
|
||||
} else {
|
||||
// Move forwards through history
|
||||
if (historyIndex > -1) {
|
||||
newIndex = historyIndex - 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (newIndex === historyIndex) {
|
||||
// Only handle up/down keys with Cmd/Ctrl modifier
|
||||
if ((!isUp && !isDown) || !(evt.metaKey || evt.ctrlKey) || evt.altKey || evt.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update index and value
|
||||
evt.preventDefault();
|
||||
|
||||
// Get global history once to avoid multiple calls
|
||||
const globalHistory = LocalMessageStorage.getRecentMessages() || [];
|
||||
|
||||
// Save current input if we're just starting to navigate history
|
||||
if (historyIndex === -1) {
|
||||
setSavedInput(displayValue || '');
|
||||
setIsInGlobalHistory(commandHistory.length === 0);
|
||||
}
|
||||
|
||||
// Determine which history we're using
|
||||
const currentHistory = isInGlobalHistory ? globalHistory : commandHistory;
|
||||
let newIndex = historyIndex;
|
||||
let newValue = '';
|
||||
|
||||
// Handle navigation
|
||||
if (isUp) {
|
||||
// Moving up through history
|
||||
if (newIndex < currentHistory.length - 1) {
|
||||
// Still have items in current history
|
||||
newIndex = historyIndex + 1;
|
||||
newValue = currentHistory[newIndex];
|
||||
} else if (!isInGlobalHistory && globalHistory.length > 0) {
|
||||
// Switch to global history
|
||||
setIsInGlobalHistory(true);
|
||||
newIndex = 0;
|
||||
newValue = globalHistory[newIndex];
|
||||
}
|
||||
} else {
|
||||
// Moving down through history
|
||||
if (newIndex > 0) {
|
||||
// Still have items in current history
|
||||
newIndex = historyIndex - 1;
|
||||
newValue = currentHistory[newIndex];
|
||||
} else if (isInGlobalHistory && commandHistory.length > 0) {
|
||||
// Switch to chat history
|
||||
setIsInGlobalHistory(false);
|
||||
newIndex = commandHistory.length - 1;
|
||||
newValue = commandHistory[newIndex];
|
||||
} else {
|
||||
// Return to original input
|
||||
newIndex = -1;
|
||||
newValue = savedInput;
|
||||
}
|
||||
}
|
||||
|
||||
// Update display if we have a new value
|
||||
if (newIndex !== historyIndex) {
|
||||
setHistoryIndex(newIndex);
|
||||
if (newIndex === -1) {
|
||||
// Restore saved input when going past the end of history
|
||||
setDisplayValue(savedInput);
|
||||
setValue(savedInput);
|
||||
setDisplayValue(savedInput || '');
|
||||
setValue(savedInput || '');
|
||||
} else {
|
||||
setDisplayValue(commandHistory[newIndex] || '');
|
||||
setValue(commandHistory[newIndex] || '');
|
||||
setDisplayValue(newValue || '');
|
||||
setValue(newValue || '');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (evt: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Handle command history navigation
|
||||
if ((evt.metaKey || evt.ctrlKey) && (evt.key === 'ArrowUp' || evt.key === 'ArrowDown')) {
|
||||
// Handle history navigation first
|
||||
handleHistoryNavigation(evt);
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.key === 'Enter') {
|
||||
// should not trigger submit on Enter if it's composing (IME input in progress) or shift/alt(option) is pressed
|
||||
@@ -180,11 +214,15 @@ export default function Input({
|
||||
|
||||
// Only submit if not loading and has content
|
||||
if (!isLoading && displayValue.trim()) {
|
||||
// Always add to global chat storage before submitting
|
||||
LocalMessageStorage.addMessage(displayValue);
|
||||
|
||||
handleSubmit(new CustomEvent('submit', { detail: { value: displayValue } }));
|
||||
setDisplayValue('');
|
||||
setValue('');
|
||||
setHistoryIndex(-1);
|
||||
setSavedInput('');
|
||||
setIsInGlobalHistory(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -192,11 +230,15 @@ export default function Input({
|
||||
const onFormSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (displayValue.trim() && !isLoading) {
|
||||
// Always add to global chat storage before submitting
|
||||
LocalMessageStorage.addMessage(displayValue);
|
||||
|
||||
handleSubmit(new CustomEvent('submit', { detail: { value: displayValue } }));
|
||||
setDisplayValue('');
|
||||
setValue('');
|
||||
setHistoryIndex(-1);
|
||||
setSavedInput('');
|
||||
setIsInGlobalHistory(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -239,7 +281,7 @@ export default function Input({
|
||||
maxHeight: `${maxHeight}px`,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
className="w-full pl-4 pr-[68px] outline-none border-none focus:ring-0 bg-transparent pt-3 pb-1.5 text-sm resize-none text-textStandard"
|
||||
className="w-full pl-4 pr-[68px] outline-none border-none focus:ring-0 bg-transparent pt-3 pb-1.5 text-sm resize-none text-textStandard placeholder:text-textPlaceholder placeholder:opacity-50"
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react';
|
||||
import { getApiUrl } from '../config';
|
||||
import FlappyGoose from './FlappyGoose';
|
||||
import GooseMessage from './GooseMessage';
|
||||
import ChatInput from './ChatInput';
|
||||
import { type View, ViewOptions } from '../App';
|
||||
import LoadingGoose from './LoadingGoose';
|
||||
import MoreMenuLayout from './more_menu/MoreMenuLayout';
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
useChatContextManager,
|
||||
} from './context_management/ContextManager';
|
||||
import { ContextLengthExceededHandler } from './context_management/ContextLengthExceededHandler';
|
||||
import { LocalMessageStorage } from '../utils/localMessageStorage';
|
||||
import {
|
||||
Message,
|
||||
createUserMessage,
|
||||
@@ -31,14 +33,12 @@ import {
|
||||
ToolRequestMessageContent,
|
||||
ToolResponseMessageContent,
|
||||
ToolConfirmationRequestMessageContent,
|
||||
getTextContent,
|
||||
} from '../types/message';
|
||||
import ChatInput from './ChatInput';
|
||||
|
||||
export interface ChatType {
|
||||
id: string;
|
||||
title: string;
|
||||
// messages up to this index are presumed to be "history" from a resumed session, this is used to track older tool confirmation requests
|
||||
// anything before this index should not render any buttons, but anything after should
|
||||
messageHistoryIndex: number;
|
||||
messages: Message[];
|
||||
}
|
||||
@@ -88,8 +88,6 @@ function ChatContent({
|
||||
setView: (view: View, viewOptions?: ViewOptions) => void;
|
||||
setIsGoosehintsModalOpen: (isOpen: boolean) => void;
|
||||
}) {
|
||||
// Disabled askAi calls to save costs
|
||||
// const [messageMetadata, setMessageMetadata] = useState<Record<string, string[]>>({});
|
||||
const [hasMessages, setHasMessages] = useState(false);
|
||||
const [lastInteractionTime, setLastInteractionTime] = useState<number>(Date.now());
|
||||
const [showGame, setShowGame] = useState(false);
|
||||
@@ -121,9 +119,19 @@ function ChatContent({
|
||||
// Get recipeConfig directly from appConfig
|
||||
const recipeConfig = window.appConfig.get('recipeConfig') as Recipe | null;
|
||||
|
||||
// Store message in global history when it's added
|
||||
const storeMessageInHistory = useCallback((message: Message) => {
|
||||
if (isUserMessage(message)) {
|
||||
const text = getTextContent(message);
|
||||
if (text) {
|
||||
LocalMessageStorage.addMessage(text);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const {
|
||||
messages,
|
||||
append,
|
||||
append: originalAppend,
|
||||
stop,
|
||||
isLoading,
|
||||
error,
|
||||
@@ -146,11 +154,6 @@ function ChatContent({
|
||||
}
|
||||
}, 300);
|
||||
|
||||
// Disabled askAi calls to save costs
|
||||
// const messageText = getTextContent(message);
|
||||
// const fetchResponses = await askAi(messageText);
|
||||
// setMessageMetadata((prev) => ({ ...prev, [message.id || '']: fetchResponses }));
|
||||
|
||||
const timeSinceLastInteraction = Date.now() - lastInteractionTime;
|
||||
window.electron.logInfo('last interaction:' + lastInteractionTime);
|
||||
if (timeSinceLastInteraction > 60000) {
|
||||
@@ -163,6 +166,17 @@ function ChatContent({
|
||||
},
|
||||
});
|
||||
|
||||
// Wrap append to store messages in global history
|
||||
const append = useCallback(
|
||||
(messageOrString: Message | string) => {
|
||||
const message =
|
||||
typeof messageOrString === 'string' ? createUserMessage(messageOrString) : messageOrString;
|
||||
storeMessageInHistory(message);
|
||||
return originalAppend(message);
|
||||
},
|
||||
[originalAppend, storeMessageInHistory]
|
||||
);
|
||||
|
||||
// for CLE events -- create a new session id for the next set of messages
|
||||
useEffect(() => {
|
||||
// If we're in a continuation session, update the chat ID
|
||||
@@ -248,8 +262,6 @@ function ChatContent({
|
||||
window.removeEventListener('make-agent-from-chat', handleMakeAgent);
|
||||
};
|
||||
}, [messages]);
|
||||
// do we need append here?
|
||||
// }, [append, chat.messages]);
|
||||
|
||||
// Update chat messages when they change and save to sessionStorage
|
||||
useEffect(() => {
|
||||
@@ -495,7 +507,7 @@ function ChatContent({
|
||||
)}
|
||||
{messages.length === 0 ? (
|
||||
<Splash
|
||||
append={(text) => append(createUserMessage(text))}
|
||||
append={append}
|
||||
activities={Array.isArray(recipeConfig?.activities) ? recipeConfig.activities : null}
|
||||
title={recipeConfig?.title}
|
||||
/>
|
||||
@@ -526,7 +538,7 @@ function ChatContent({
|
||||
messageHistoryIndex={chat?.messageHistoryIndex}
|
||||
message={message}
|
||||
messages={messages}
|
||||
append={(text) => append(createUserMessage(text))}
|
||||
append={append}
|
||||
appendMessage={(newMessage) => {
|
||||
const updatedMessages = [...messages, newMessage];
|
||||
setMessages(updatedMessages);
|
||||
|
||||
@@ -17,6 +17,8 @@ import ToolCallConfirmation from './ToolCallConfirmation';
|
||||
import MessageCopyLink from './MessageCopyLink';
|
||||
|
||||
interface GooseMessageProps {
|
||||
// messages up to this index are presumed to be "history" from a resumed session, this is used to track older tool confirmation requests
|
||||
// anything before this index should not render any buttons, but anything after should
|
||||
messageHistoryIndex: number;
|
||||
message: Message;
|
||||
messages: Message[];
|
||||
|
||||
76
ui/desktop/src/utils/localMessageStorage.ts
Normal file
76
ui/desktop/src/utils/localMessageStorage.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
interface StoredMessage {
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'goose-chat-history';
|
||||
const MAX_MESSAGES = 500;
|
||||
const EXPIRY_DAYS = 30;
|
||||
|
||||
export class LocalMessageStorage {
|
||||
private static getStoredMessages(): StoredMessage[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) return [];
|
||||
|
||||
const messages = JSON.parse(stored) as StoredMessage[];
|
||||
const now = Date.now();
|
||||
const expiryTime = now - EXPIRY_DAYS * 24 * 60 * 60 * 1000;
|
||||
|
||||
// Filter out expired messages and limit to max count
|
||||
const validMessages = messages
|
||||
.filter((msg) => msg.timestamp > expiryTime)
|
||||
.slice(-MAX_MESSAGES);
|
||||
|
||||
// If we filtered any messages, update storage
|
||||
if (validMessages.length !== messages.length) {
|
||||
this.setStoredMessages(validMessages);
|
||||
}
|
||||
|
||||
return validMessages;
|
||||
} catch (error) {
|
||||
console.error('Error reading message history:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static setStoredMessages(messages: StoredMessage[]) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(messages));
|
||||
} catch (error) {
|
||||
console.error('Error saving message history:', error);
|
||||
}
|
||||
}
|
||||
|
||||
static addMessage(content: string) {
|
||||
if (!content.trim()) return;
|
||||
|
||||
const messages = this.getStoredMessages();
|
||||
const now = Date.now();
|
||||
|
||||
// Don't add duplicate of last message
|
||||
if (messages.length > 0 && messages[messages.length - 1].content === content) {
|
||||
return;
|
||||
}
|
||||
|
||||
messages.push({
|
||||
content,
|
||||
timestamp: now,
|
||||
});
|
||||
|
||||
// Keep only the most recent MAX_MESSAGES
|
||||
const validMessages = messages.slice(-MAX_MESSAGES);
|
||||
|
||||
this.setStoredMessages(validMessages);
|
||||
}
|
||||
|
||||
static getRecentMessages(): string[] {
|
||||
return this.getStoredMessages()
|
||||
.map((msg) => msg.content)
|
||||
.reverse(); // Most recent first
|
||||
}
|
||||
|
||||
static clearHistory() {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user