Global/local chat history store in localstorage (#2428)

This commit is contained in:
Zane
2025-05-05 09:05:05 -07:00
committed by GitHub
parent ab870e9b70
commit 6c0f8d648f
5 changed files with 203 additions and 72 deletions

View File

@@ -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",

View File

@@ -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 ? (

View File

@@ -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);

View File

@@ -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[];

View 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);
}
}