Store Chat Prompt Input Value in Local Storage (#4680)

* Add logic to save prompt input state to local storage and use as initial state on mount

* Synchronize prompt input state with parent component on mount

* lint

* Clear USER_PROMPT_INPUT_VALUE local storage value in all instances of auth clearing

* Remove USER_PROMPT_INPUT_VALUE local storage `removeItem` logic from excessive sources

* Refactor logic to cache prompt input value state by thread | abstract into a custom hook | rename localStorage key variables for clarity.

* Remove console log statement from usePromptInputStorage hook to clean up code.

* Update comments in usePromptInputStorage hook for clarity on localStorage handling

* Implement debounced localStorage updates in usePromptInputStorage hook to improve performance and prevent writing on every keystroke.

* Refactor localStorage handling in usePromptInputStorage hook to utilize safeJsonParse | Remove uneeeded comments

* Remove useEffect cleanup comment

---------

Co-authored-by: Timothy Carambat <rambat1010@gmail.com>
This commit is contained in:
Marcello Fitton 2025-12-02 13:45:26 -08:00 committed by GitHub
parent df493d5413
commit 8ffb7eb6e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 89 additions and 1 deletions

View File

@ -1,5 +1,10 @@
import React, { useState, createContext } from "react";
import { AUTH_TIMESTAMP, AUTH_TOKEN, AUTH_USER } from "@/utils/constants";
import {
AUTH_TIMESTAMP,
AUTH_TOKEN,
AUTH_USER,
USER_PROMPT_INPUT_MAP,
} from "@/utils/constants";
export const AuthContext = createContext(null);
export function AuthProvider(props) {
@ -20,6 +25,7 @@ export function AuthProvider(props) {
localStorage.removeItem(AUTH_USER);
localStorage.removeItem(AUTH_TOKEN);
localStorage.removeItem(AUTH_TIMESTAMP);
localStorage.removeItem(USER_PROMPT_INPUT_MAP);
setStore({ user: null, authToken: null });
},
});

View File

@ -12,6 +12,7 @@ import {
AUTH_TOKEN,
AUTH_USER,
LAST_VISITED_WORKSPACE,
USER_PROMPT_INPUT_MAP,
} from "@/utils/constants";
import { useTranslation } from "react-i18next";
@ -97,6 +98,7 @@ export default function UserButton() {
window.localStorage.removeItem(AUTH_TOKEN);
window.localStorage.removeItem(AUTH_TIMESTAMP);
window.localStorage.removeItem(LAST_VISITED_WORKSPACE);
window.localStorage.removeItem(USER_PROMPT_INPUT_MAP);
window.location.replace(paths.home());
}}
type="button"

View File

@ -24,6 +24,7 @@ import {
import useTextSize from "@/hooks/useTextSize";
import { useTranslation } from "react-i18next";
import Appearance from "@/models/appearance";
import usePromptInputStorage from "@/hooks/usePromptInputStorage";
export const PROMPT_INPUT_ID = "primary-prompt-input";
export const PROMPT_INPUT_EVENT = "set_prompt_input";
@ -48,6 +49,13 @@ export default function PromptInput({
const redoStack = useRef([]);
const { textSizeClass } = useTextSize();
// Synchronizes prompt input value with localStorage, scoped to the current thread.
usePromptInputStorage({
onChange,
promptInput,
setPromptInput,
});
/**
* To prevent too many re-renders we remotely listen for updates from the parent
* via an event cycle. Otherwise, using message as a prop leads to a re-render every

View File

@ -0,0 +1,71 @@
import { USER_PROMPT_INPUT_MAP } from "@/utils/constants";
import { useEffect, useMemo } from "react";
import { useParams } from "react-router-dom";
import debounce from "lodash.debounce";
import { safeJsonParse } from "@/utils/request";
/**
* Synchronizes prompt input value with localStorage, scoped to the current thread.
*
* Persists unsent prompt text across page refreshes and navigation. Each thread/workspace maintains
* its own draft state independently. Storage key is determined by thread slug (if in a thread) or
* workspace slug (if in default chat).
*
* Storage format (stored under USER_PROMPT_INPUT_MAP key):
* ```json
* {
* "thread-slug": "user's draft message...",
* "workspace-slug": "another draft message..."
* }
* ```
*
* @param {Object} props
* @param {Function} props.onChange - Callback invoked when restoring saved value, receives `{ target: { value: string } }`
* @param {string} props.promptInput - Current prompt input value to sync
* @param {Function} props.setPromptInput - State setter function for prompt input
* @returns {void}
*/
export default function usePromptInputStorage({
onChange,
promptInput,
setPromptInput,
}) {
const { threadSlug = null, slug: workspaceSlug } = useParams();
useEffect(() => {
const serializedPromptInputMap =
localStorage.getItem(USER_PROMPT_INPUT_MAP) || "{}";
const promptInputMap = safeJsonParse(serializedPromptInputMap, {});
const userPromptInputValue = promptInputMap[threadSlug ?? workspaceSlug];
if (userPromptInputValue) {
setPromptInput(userPromptInputValue);
// Notify parent component so message state is synchronized
onChange({ target: { value: userPromptInputValue } });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const debouncedWriteToStorage = useMemo(
() =>
debounce((value, slug) => {
const serializedPromptInputMap =
localStorage.getItem(USER_PROMPT_INPUT_MAP) || "{}";
const promptInputMap = safeJsonParse(serializedPromptInputMap, {});
promptInputMap[slug] = value;
localStorage.setItem(
USER_PROMPT_INPUT_MAP,
JSON.stringify(promptInputMap)
);
}, 500),
[]
);
useEffect(() => {
debouncedWriteToStorage(promptInput, threadSlug ?? workspaceSlug);
return () => {
debouncedWriteToStorage.cancel();
};
}, [promptInput, threadSlug, workspaceSlug, debouncedWriteToStorage]);
}

View File

@ -8,6 +8,7 @@ export const COMPLETE_QUESTIONNAIRE = "anythingllm_completed_questionnaire";
export const SEEN_DOC_PIN_ALERT = "anythingllm_pinned_document_alert";
export const SEEN_WATCH_ALERT = "anythingllm_watched_document_alert";
export const LAST_VISITED_WORKSPACE = "anythingllm_last_visited_workspace";
export const USER_PROMPT_INPUT_MAP = "anythingllm_user_prompt_input_map";
export const APPEARANCE_SETTINGS = "anythingllm_appearance_settings";