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": "prettier --write \"src/**/*.{ts,tsx,css,json}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,css,json}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx,css,json}\"",
"prepare": "cd ../.. && husky install", "prepare": "cd ../.. && husky install",
"start-alpha-gui": "ALPHA=true npm run start-gui", "start-alpha-gui": "ALPHA=true npm run start-gui"
"start-alpha-server": "cd ../.. && just run-ui-alpha"
}, },
"devDependencies": { "devDependencies": {
"@electron-forge/cli": "^7.5.0", "@electron-forge/cli": "^7.5.0",

View File

@@ -5,19 +5,20 @@ import Stop from './ui/Stop';
import { Attach, Send } from './icons'; import { Attach, Send } from './icons';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import BottomMenu from './bottom_menu/BottomMenu'; import BottomMenu from './bottom_menu/BottomMenu';
import { LocalMessageStorage } from '../utils/localMessageStorage';
interface InputProps { interface ChatInputProps {
handleSubmit: (e: React.FormEvent) => void; handleSubmit: (e: React.FormEvent) => void;
isLoading?: boolean; isLoading?: boolean;
onStop?: () => void; onStop?: () => void;
commandHistory?: string[]; commandHistory?: string[]; // Current chat's message history
initialValue?: string; initialValue?: string;
droppedFiles?: string[]; droppedFiles?: string[];
setView: (view: View) => void; setView: (view: View) => void;
numTokens?: number; numTokens?: number;
} }
export default function Input({ export default function ChatInput({
handleSubmit, handleSubmit,
isLoading = false, isLoading = false,
onStop, onStop,
@@ -26,23 +27,25 @@ export default function Input({
setView, setView,
numTokens, numTokens,
droppedFiles = [], droppedFiles = [],
}: InputProps) { }: ChatInputProps) {
const [_value, setValue] = useState(initialValue); const [_value, setValue] = useState(initialValue);
const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
// Update internal value when initialValue changes // Update internal value when initialValue changes
useEffect(() => { useEffect(() => {
if (initialValue) { setValue(initialValue);
setValue(initialValue); setDisplayValue(initialValue);
setDisplayValue(initialValue); // Reset history index when input is cleared
} setHistoryIndex(-1);
setIsInGlobalHistory(false);
}, [initialValue]); }, [initialValue]);
// State to track if the IME is composing (i.e., in the middle of Japanese IME input) // State to track if the IME is composing (i.e., in the middle of Japanese IME input)
const [isComposing, setIsComposing] = useState(false); const [isComposing, setIsComposing] = useState(false);
const [historyIndex, setHistoryIndex] = useState(-1); const [historyIndex, setHistoryIndex] = useState(-1);
const [savedInput, setSavedInput] = useState(''); const [savedInput, setSavedInput] = useState('');
const [isInGlobalHistory, setIsInGlobalHistory] = useState(false);
const textAreaRef = useRef<HTMLTextAreaElement>(null); const textAreaRef = useRef<HTMLTextAreaElement>(null);
const [processedFilePaths, setProcessedFilePaths] = useState<string[]>([]); const [processedFilePaths, setProcessedFilePaths] = useState<string[]>([]);
@@ -56,17 +59,20 @@ export default function Input({
const maxHeight = 10 * 24; const maxHeight = 10 * 24;
// If we have dropped files, add them to the input and update our state. // If we have dropped files, add them to the input and update our state.
if (processedFilePaths !== droppedFiles) { useEffect(() => {
// Append file paths that aren't in displayValue. if (processedFilePaths !== droppedFiles && droppedFiles.length > 0) {
let joinedPaths = // Append file paths that aren't in displayValue.
displayValue.trim() + const currentText = displayValue || '';
' ' + const joinedPaths = currentText.trim()
droppedFiles.filter((path) => !displayValue.includes(path)).join(' '); ? `${currentText.trim()} ${droppedFiles.filter((path) => !currentText.includes(path)).join(' ')}`
setDisplayValue(joinedPaths); : droppedFiles.join(' ');
setValue(joinedPaths);
textAreaRef.current?.focus(); setDisplayValue(joinedPaths);
setProcessedFilePaths(droppedFiles); setValue(joinedPaths);
} textAreaRef.current?.focus();
setProcessedFilePaths(droppedFiles);
}
}, [droppedFiles, processedFilePaths, displayValue]);
// Debounced function to update actual value // Debounced function to update actual value
const debouncedSetValue = useCallback((val: string) => { const debouncedSetValue = useCallback((val: string) => {
@@ -117,49 +123,77 @@ export default function Input({
}; };
const handleHistoryNavigation = (evt: React.KeyboardEvent<HTMLTextAreaElement>) => { 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 // Only handle up/down keys with Cmd/Ctrl modifier
if (historyIndex === -1) { if ((!isUp && !isDown) || !(evt.metaKey || evt.ctrlKey) || evt.altKey || evt.shiftKey) {
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) {
return; return;
} }
// Update index and value evt.preventDefault();
setHistoryIndex(newIndex);
if (newIndex === -1) { // Get global history once to avoid multiple calls
// Restore saved input when going past the end of history const globalHistory = LocalMessageStorage.getRecentMessages() || [];
setDisplayValue(savedInput);
setValue(savedInput); // 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 { } else {
setDisplayValue(commandHistory[newIndex] || ''); // Moving down through history
setValue(commandHistory[newIndex] || ''); 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) {
setDisplayValue(savedInput || '');
setValue(savedInput || '');
} else {
setDisplayValue(newValue || '');
setValue(newValue || '');
}
} }
}; };
const handleKeyDown = (evt: React.KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyDown = (evt: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Handle command history navigation // Handle history navigation first
if ((evt.metaKey || evt.ctrlKey) && (evt.key === 'ArrowUp' || evt.key === 'ArrowDown')) { handleHistoryNavigation(evt);
handleHistoryNavigation(evt);
return;
}
if (evt.key === 'Enter') { if (evt.key === 'Enter') {
// should not trigger submit on Enter if it's composing (IME input in progress) or shift/alt(option) is pressed // 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 // Only submit if not loading and has content
if (!isLoading && displayValue.trim()) { if (!isLoading && displayValue.trim()) {
// Always add to global chat storage before submitting
LocalMessageStorage.addMessage(displayValue);
handleSubmit(new CustomEvent('submit', { detail: { value: displayValue } })); handleSubmit(new CustomEvent('submit', { detail: { value: displayValue } }));
setDisplayValue(''); setDisplayValue('');
setValue(''); setValue('');
setHistoryIndex(-1); setHistoryIndex(-1);
setSavedInput(''); setSavedInput('');
setIsInGlobalHistory(false);
} }
} }
}; };
@@ -192,11 +230,15 @@ export default function Input({
const onFormSubmit = (e: React.FormEvent) => { const onFormSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (displayValue.trim() && !isLoading) { if (displayValue.trim() && !isLoading) {
// Always add to global chat storage before submitting
LocalMessageStorage.addMessage(displayValue);
handleSubmit(new CustomEvent('submit', { detail: { value: displayValue } })); handleSubmit(new CustomEvent('submit', { detail: { value: displayValue } }));
setDisplayValue(''); setDisplayValue('');
setValue(''); setValue('');
setHistoryIndex(-1); setHistoryIndex(-1);
setSavedInput(''); setSavedInput('');
setIsInGlobalHistory(false);
} }
}; };
@@ -239,7 +281,7 @@ export default function Input({
maxHeight: `${maxHeight}px`, maxHeight: `${maxHeight}px`,
overflowY: 'auto', 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 ? ( {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 { getApiUrl } from '../config';
import FlappyGoose from './FlappyGoose'; import FlappyGoose from './FlappyGoose';
import GooseMessage from './GooseMessage'; import GooseMessage from './GooseMessage';
import ChatInput from './ChatInput';
import { type View, ViewOptions } from '../App'; import { type View, ViewOptions } from '../App';
import LoadingGoose from './LoadingGoose'; import LoadingGoose from './LoadingGoose';
import MoreMenuLayout from './more_menu/MoreMenuLayout'; import MoreMenuLayout from './more_menu/MoreMenuLayout';
@@ -23,6 +24,7 @@ import {
useChatContextManager, useChatContextManager,
} from './context_management/ContextManager'; } from './context_management/ContextManager';
import { ContextLengthExceededHandler } from './context_management/ContextLengthExceededHandler'; import { ContextLengthExceededHandler } from './context_management/ContextLengthExceededHandler';
import { LocalMessageStorage } from '../utils/localMessageStorage';
import { import {
Message, Message,
createUserMessage, createUserMessage,
@@ -31,14 +33,12 @@ import {
ToolRequestMessageContent, ToolRequestMessageContent,
ToolResponseMessageContent, ToolResponseMessageContent,
ToolConfirmationRequestMessageContent, ToolConfirmationRequestMessageContent,
getTextContent,
} from '../types/message'; } from '../types/message';
import ChatInput from './ChatInput';
export interface ChatType { export interface ChatType {
id: string; id: string;
title: 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; messageHistoryIndex: number;
messages: Message[]; messages: Message[];
} }
@@ -88,8 +88,6 @@ function ChatContent({
setView: (view: View, viewOptions?: ViewOptions) => void; setView: (view: View, viewOptions?: ViewOptions) => void;
setIsGoosehintsModalOpen: (isOpen: boolean) => void; setIsGoosehintsModalOpen: (isOpen: boolean) => void;
}) { }) {
// Disabled askAi calls to save costs
// const [messageMetadata, setMessageMetadata] = useState<Record<string, string[]>>({});
const [hasMessages, setHasMessages] = useState(false); const [hasMessages, setHasMessages] = useState(false);
const [lastInteractionTime, setLastInteractionTime] = useState<number>(Date.now()); const [lastInteractionTime, setLastInteractionTime] = useState<number>(Date.now());
const [showGame, setShowGame] = useState(false); const [showGame, setShowGame] = useState(false);
@@ -121,9 +119,19 @@ function ChatContent({
// Get recipeConfig directly from appConfig // Get recipeConfig directly from appConfig
const recipeConfig = window.appConfig.get('recipeConfig') as Recipe | null; 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 { const {
messages, messages,
append, append: originalAppend,
stop, stop,
isLoading, isLoading,
error, error,
@@ -146,11 +154,6 @@ function ChatContent({
} }
}, 300); }, 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; const timeSinceLastInteraction = Date.now() - lastInteractionTime;
window.electron.logInfo('last interaction:' + lastInteractionTime); window.electron.logInfo('last interaction:' + lastInteractionTime);
if (timeSinceLastInteraction > 60000) { 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 // for CLE events -- create a new session id for the next set of messages
useEffect(() => { useEffect(() => {
// If we're in a continuation session, update the chat ID // If we're in a continuation session, update the chat ID
@@ -248,8 +262,6 @@ function ChatContent({
window.removeEventListener('make-agent-from-chat', handleMakeAgent); window.removeEventListener('make-agent-from-chat', handleMakeAgent);
}; };
}, [messages]); }, [messages]);
// do we need append here?
// }, [append, chat.messages]);
// Update chat messages when they change and save to sessionStorage // Update chat messages when they change and save to sessionStorage
useEffect(() => { useEffect(() => {
@@ -495,7 +507,7 @@ function ChatContent({
)} )}
{messages.length === 0 ? ( {messages.length === 0 ? (
<Splash <Splash
append={(text) => append(createUserMessage(text))} append={append}
activities={Array.isArray(recipeConfig?.activities) ? recipeConfig.activities : null} activities={Array.isArray(recipeConfig?.activities) ? recipeConfig.activities : null}
title={recipeConfig?.title} title={recipeConfig?.title}
/> />
@@ -526,7 +538,7 @@ function ChatContent({
messageHistoryIndex={chat?.messageHistoryIndex} messageHistoryIndex={chat?.messageHistoryIndex}
message={message} message={message}
messages={messages} messages={messages}
append={(text) => append(createUserMessage(text))} append={append}
appendMessage={(newMessage) => { appendMessage={(newMessage) => {
const updatedMessages = [...messages, newMessage]; const updatedMessages = [...messages, newMessage];
setMessages(updatedMessages); setMessages(updatedMessages);

View File

@@ -17,6 +17,8 @@ import ToolCallConfirmation from './ToolCallConfirmation';
import MessageCopyLink from './MessageCopyLink'; import MessageCopyLink from './MessageCopyLink';
interface GooseMessageProps { 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; messageHistoryIndex: number;
message: Message; message: Message;
messages: 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);
}
}