diff --git a/docs/core_docs/docs/integrations/memory/file.mdx b/docs/core_docs/docs/integrations/memory/file.mdx new file mode 100644 index 000000000000..d485be9d29c6 --- /dev/null +++ b/docs/core_docs/docs/integrations/memory/file.mdx @@ -0,0 +1,31 @@ +--- +hide_table_of_contents: true +--- + +import CodeBlock from "@theme/CodeBlock"; + +# File Chat Message History + +The `FileChatMessageHistory` uses a JSON file to store chat message history. For longer-term persistence across chat sessions, you can swap out the default in-memory `chatHistory` that backs chat memory classes like `BufferMemory`. + +## Setup + +You'll first need to install the [`@langchain/community`](https://www.npmjs.com/package/@langchain/community) package: + +```bash npm2yarn +npm install @langchain/community @langchain/core +``` + +import IntegrationInstallTooltip from "@mdx_components/integration_install_tooltip.mdx"; + + + +```bash npm2yarn +npm install @langchain/openai @langchain/community @langchain/core +``` + +## Usage + +import Example from "@examples/memory/file.ts"; + +{Example} diff --git a/examples/src/memory/file.ts b/examples/src/memory/file.ts new file mode 100644 index 000000000000..5728ec3af26d --- /dev/null +++ b/examples/src/memory/file.ts @@ -0,0 +1,71 @@ +import { ChatOpenAI } from "@langchain/openai"; +import { FileSystemChatMessageHistory } from "@langchain/community/stores/message/file_system"; +import { RunnableWithMessageHistory } from "@langchain/core/runnables"; +import { StringOutputParser } from "@langchain/core/output_parsers"; +import { + ChatPromptTemplate, + MessagesPlaceholder, +} from "@langchain/core/prompts"; + +const model = new ChatOpenAI({ + model: "gpt-3.5-turbo", + temperature: 0, +}); + +const prompt = ChatPromptTemplate.fromMessages([ + [ + "system", + "You are a helpful assistant. Answer all questions to the best of your ability.", + ], + new MessagesPlaceholder("chat_history"), + ["human", "{input}"], +]); + +const chain = prompt.pipe(model).pipe(new StringOutputParser()); + +const chainWithHistory = new RunnableWithMessageHistory({ + runnable: chain, + inputMessagesKey: "input", + historyMessagesKey: "chat_history", + getMessageHistory: async (sessionId) => { + const chatHistory = new FileSystemChatMessageHistory({ + sessionId, + userId: "user-id", + }); + return chatHistory; + }, +}); + +const res1 = await chainWithHistory.invoke( + { input: "Hi! I'm Jim." }, + { configurable: { sessionId: "langchain-test-session" } } +); +console.log({ res1 }); +/* + { res1: 'Hi Jim! How can I assist you today?' } + */ + +const res2 = await chainWithHistory.invoke( + { input: "What did I just say my name was?" }, + { configurable: { sessionId: "langchain-test-session" } } +); +console.log({ res2 }); +/* + { res2: { response: 'You said your name was Jim.' } + */ + +// Give this session a title +const chatHistory = (await chainWithHistory.getMessageHistory( + "langchain-test-session" +)) as FileSystemChatMessageHistory; + +await chatHistory.setContext({ title: "Introducing Jim" }); + +// List all session for the user +const sessions = await chatHistory.getAllSessions(); +console.log(sessions); +/* + [ + { sessionId: 'langchain-test-session', context: { title: "Introducing Jim" } } + ] + */ diff --git a/libs/langchain-community/.gitignore b/libs/langchain-community/.gitignore index e6ae5fa54a4f..b40fdcd61a74 100644 --- a/libs/langchain-community/.gitignore +++ b/libs/langchain-community/.gitignore @@ -770,6 +770,10 @@ stores/message/firestore.cjs stores/message/firestore.js stores/message/firestore.d.ts stores/message/firestore.d.cts +stores/message/file_system.cjs +stores/message/file_system.js +stores/message/file_system.d.ts +stores/message/file_system.d.cts stores/message/in_memory.cjs stores/message/in_memory.js stores/message/in_memory.d.ts diff --git a/libs/langchain-community/langchain.config.js b/libs/langchain-community/langchain.config.js index b5cc03c53360..b0207b8612ab 100644 --- a/libs/langchain-community/langchain.config.js +++ b/libs/langchain-community/langchain.config.js @@ -243,6 +243,7 @@ export const config = { "stores/message/convex": "stores/message/convex", "stores/message/dynamodb": "stores/message/dynamodb", "stores/message/firestore": "stores/message/firestore", + "stores/message/file_system": "stores/message/file_system", "stores/message/in_memory": "stores/message/in_memory", "stores/message/ipfs_datastore": "stores/message/ipfs_datastore", "stores/message/ioredis": "stores/message/ioredis", diff --git a/libs/langchain-community/package.json b/libs/langchain-community/package.json index 118d11f80b41..ed9efe073426 100644 --- a/libs/langchain-community/package.json +++ b/libs/langchain-community/package.json @@ -2449,6 +2449,15 @@ "import": "./stores/message/firestore.js", "require": "./stores/message/firestore.cjs" }, + "./stores/message/file_system": { + "types": { + "import": "./stores/message/file_system.d.ts", + "require": "./stores/message/file_system.d.cts", + "default": "./stores/message/file_system.d.ts" + }, + "import": "./stores/message/file_system.js", + "require": "./stores/message/file_system.cjs" + }, "./stores/message/in_memory": { "types": { "import": "./stores/message/in_memory.d.ts", @@ -3873,6 +3882,10 @@ "stores/message/firestore.js", "stores/message/firestore.d.ts", "stores/message/firestore.d.cts", + "stores/message/file_system.cjs", + "stores/message/file_system.js", + "stores/message/file_system.d.ts", + "stores/message/file_system.d.cts", "stores/message/in_memory.cjs", "stores/message/in_memory.js", "stores/message/in_memory.d.ts", diff --git a/libs/langchain-community/src/load/import_map.ts b/libs/langchain-community/src/load/import_map.ts index 8b3b734a82c1..0e3192033848 100644 --- a/libs/langchain-community/src/load/import_map.ts +++ b/libs/langchain-community/src/load/import_map.ts @@ -67,6 +67,7 @@ export * as caches__upstash_redis from "../caches/upstash_redis.js"; export * as stores__doc__base from "../stores/doc/base.js"; export * as stores__doc__gcs from "../stores/doc/gcs.js"; export * as stores__doc__in_memory from "../stores/doc/in_memory.js"; +export * as stores__message__file_system from "../stores/message/file_system.js"; export * as stores__message__in_memory from "../stores/message/in_memory.js"; export * as memory__chat_memory from "../memory/chat_memory.js"; export * as indexes__base from "../indexes/base.js"; diff --git a/libs/langchain-community/src/stores/message/file_system.ts b/libs/langchain-community/src/stores/message/file_system.ts new file mode 100644 index 000000000000..f81af5f7a4ef --- /dev/null +++ b/libs/langchain-community/src/stores/message/file_system.ts @@ -0,0 +1,199 @@ +import { promises as fs } from "node:fs"; +import { dirname } from "node:path"; + +import { BaseListChatMessageHistory } from "@langchain/core/chat_history"; +import { + BaseMessage, + StoredMessage, + mapChatMessagesToStoredMessages, + mapStoredMessagesToChatMessages, +} from "@langchain/core/messages"; + +export const FILE_HISTORY_DEFAULT_FILE_PATH = ".history/history.json"; + +/** + * Represents a lightweight file chat session. + */ +export type FileChatSession = { + id: string; + context: Record; +}; + +/** + * Represents a stored chat session. + */ +export type StoredFileChatSession = FileChatSession & { + messages: StoredMessage[]; +}; + +/** + * Type for the store of chat sessions. + */ +export type FileChatStore = { + [userId: string]: Record; +}; + +/** + * Type for the input to the `FileSystemChatMessageHistory` constructor. + */ +export interface FileSystemChatMessageHistoryInput { + sessionId: string; + userId?: string; + filePath?: string; +} + +let store: FileChatStore; + +/** + * Store chat message history using a local JSON file. + * For demo and development purposes only. + * + * @example + * ```typescript + * const model = new ChatOpenAI({ + * model: "gpt-3.5-turbo", + * temperature: 0, + * }); + * const prompt = ChatPromptTemplate.fromMessages([ + * [ + * "system", + * "You are a helpful assistant. Answer all questions to the best of your ability.", + * ], + * ["placeholder", "chat_history"], + * ["human", "{input}"], + * ]); + * + * const chain = prompt.pipe(model).pipe(new StringOutputParser()); + * const chainWithHistory = new RunnableWithMessageHistory({ + * runnable: chain, + * inputMessagesKey: "input", + * historyMessagesKey: "chat_history", + * getMessageHistory: async (sessionId) => { + * const chatHistory = new FileSystemChatMessageHistory({ + * sessionId: sessionId, + * userId: "userId", // Optional + * }) + * return chatHistory; + * }, + * }); + * await chainWithHistory.invoke( + * { input: "What did I just say my name was?" }, + * { configurable: { sessionId: "session-id" } } + * ); + * ``` + */ +export class FileSystemChatMessageHistory extends BaseListChatMessageHistory { + lc_namespace = ["langchain", "stores", "message", "file"]; + + private sessionId: string; + + private userId: string; + + private filePath: string; + + constructor(chatHistoryInput: FileSystemChatMessageHistoryInput) { + super(); + + this.sessionId = chatHistoryInput.sessionId; + this.userId = chatHistoryInput.userId ?? ""; + this.filePath = chatHistoryInput.filePath ?? FILE_HISTORY_DEFAULT_FILE_PATH; + } + + private async init(): Promise { + if (store) { + return; + } + try { + store = await this.loadStore(); + } catch (error) { + console.error("Error initializing FileSystemChatMessageHistory:", error); + throw error; + } + } + + protected async loadStore(): Promise { + try { + await fs.access(this.filePath, fs.constants.F_OK); + const store = await fs.readFile(this.filePath, "utf-8"); + return JSON.parse(store) as FileChatStore; + } catch (_error) { + const error = _error as NodeJS.ErrnoException; + if (error.code === "ENOENT") { + return {}; + } + throw new Error( + `Error loading FileSystemChatMessageHistory store: ${error}` + ); + } + } + + protected async saveStore(): Promise { + try { + await fs.mkdir(dirname(this.filePath), { recursive: true }); + await fs.writeFile(this.filePath, JSON.stringify(store)); + } catch (error) { + throw new Error( + `Error saving FileSystemChatMessageHistory store: ${error}` + ); + } + } + + async getMessages(): Promise { + await this.init(); + const messages = store[this.userId]?.[this.sessionId]?.messages ?? []; + return mapStoredMessagesToChatMessages(messages); + } + + async addMessage(message: BaseMessage): Promise { + await this.init(); + const messages = await this.getMessages(); + messages.push(message); + const storedMessages = mapChatMessagesToStoredMessages(messages); + store[this.userId] ??= {}; + store[this.userId][this.sessionId] = { + ...store[this.userId][this.sessionId], + messages: storedMessages, + }; + await this.saveStore(); + } + + async clear(): Promise { + await this.init(); + if (store[this.userId]) { + delete store[this.userId][this.sessionId]; + } + await this.saveStore(); + } + + async getContext(): Promise> { + await this.init(); + return store[this.userId]?.[this.sessionId]?.context ?? {}; + } + + async setContext(context: Record): Promise { + await this.init(); + store[this.userId] ??= {}; + store[this.userId][this.sessionId] = { + ...store[this.userId][this.sessionId], + context, + }; + await this.saveStore(); + } + + async clearAllSessions() { + await this.init(); + delete store[this.userId]; + await this.saveStore(); + } + + async getAllSessions(): Promise { + await this.init(); + const userSessions = store[this.userId] + ? Object.values(store[this.userId]).map((session) => ({ + id: session.id, + context: session.context, + })) + : []; + return userSessions; + } +} diff --git a/libs/langchain-community/src/stores/tests/file_chat_history.int.test.ts b/libs/langchain-community/src/stores/tests/file_chat_history.int.test.ts new file mode 100644 index 000000000000..1610bcbee0e1 --- /dev/null +++ b/libs/langchain-community/src/stores/tests/file_chat_history.int.test.ts @@ -0,0 +1,147 @@ +/* eslint-disable no-promise-executor-return */ + +import { expect } from "@jest/globals"; +import { promises as fs } from "node:fs"; +import { HumanMessage, AIMessage } from "@langchain/core/messages"; +import { v4 as uuid } from "uuid"; +import { + FILE_HISTORY_DEFAULT_FILE_PATH, + FileSystemChatMessageHistory, +} from "../message/file_system.js"; + +afterAll(async () => { + try { + await fs.unlink(FILE_HISTORY_DEFAULT_FILE_PATH); + } catch { + // Ignore error if the file does not exist + } +}); + +test("FileSystemChatMessageHistory works", async () => { + const input = { + sessionId: uuid(), + }; + const chatHistory = new FileSystemChatMessageHistory(input); + const blankResult = await chatHistory.getMessages(); + expect(blankResult).toStrictEqual([]); + + await chatHistory.addUserMessage("Who is the best vocalist?"); + await chatHistory.addAIMessage("Ozzy Osbourne"); + + const expectedMessages = [ + new HumanMessage("Who is the best vocalist?"), + new AIMessage("Ozzy Osbourne"), + ]; + const resultWithHistory = await chatHistory.getMessages(); + expect(resultWithHistory).toEqual(expectedMessages); +}); + +test("FileSystemChatMessageHistory persist sessions", async () => { + const input = { + sessionId: uuid(), + }; + const chatHistory1 = new FileSystemChatMessageHistory(input); + const blankResult = await chatHistory1.getMessages(); + expect(blankResult).toStrictEqual([]); + + await chatHistory1.addUserMessage("Who is the best vocalist?"); + await chatHistory1.addAIMessage("Ozzy Osbourne"); + + const chatHistory2 = new FileSystemChatMessageHistory(input); + const expectedMessages = [ + new HumanMessage("Who is the best vocalist?"), + new AIMessage("Ozzy Osbourne"), + ]; + const resultWithHistory = await chatHistory2.getMessages(); + expect(resultWithHistory).toEqual(expectedMessages); +}); + +test("FileSystemChatMessageHistory clear session", async () => { + const input = { + sessionId: uuid(), + userId: uuid(), + }; + const chatHistory = new FileSystemChatMessageHistory(input); + + await chatHistory.addUserMessage("Who is the best vocalist?"); + await chatHistory.addAIMessage("Ozzy Osbourne"); + + const expectedMessages = [ + new HumanMessage("Who is the best vocalist?"), + new AIMessage("Ozzy Osbourne"), + ]; + const resultWithHistory = await chatHistory.getMessages(); + expect(resultWithHistory).toEqual(expectedMessages); + + await chatHistory.clear(); + + const blankResult = await chatHistory.getMessages(); + expect(blankResult).toStrictEqual([]); +}); + +test("FileSystemChatMessageHistory clear all sessions", async () => { + const input1 = { + sessionId: uuid(), + userId: "user1", + }; + const chatHistory1 = new FileSystemChatMessageHistory(input1); + + await chatHistory1.addUserMessage("Who is the best vocalist?"); + await chatHistory1.addAIMessage("Ozzy Osbourne"); + + const input2 = { + sessionId: uuid(), + userId: "user1", + }; + const chatHistory2 = new FileSystemChatMessageHistory(input2); + + await chatHistory2.addUserMessage("Who is the best vocalist?"); + await chatHistory2.addAIMessage("Ozzy Osbourne"); + + const expectedMessages = [ + new HumanMessage("Who is the best vocalist?"), + new AIMessage("Ozzy Osbourne"), + ]; + + const result1 = await chatHistory1.getMessages(); + expect(result1).toEqual(expectedMessages); + + const result2 = await chatHistory2.getMessages(); + expect(result2).toEqual(expectedMessages); + + await chatHistory1.clearAllSessions(); + + const deletedResult1 = await chatHistory1.getMessages(); + const deletedResult2 = await chatHistory2.getMessages(); + expect(deletedResult1).toStrictEqual([]); + expect(deletedResult2).toStrictEqual([]); +}); + +test("FileSystemChatMessageHistory set context and get all sessions", async () => { + const session1 = { + sessionId: uuid(), + userId: "user1", + }; + const context1 = { title: "Best vocalist" }; + const chatHistory1 = new FileSystemChatMessageHistory(session1); + + await chatHistory1.setContext(context1); + await chatHistory1.addUserMessage("Who is the best vocalist?"); + await chatHistory1.addAIMessage("Ozzy Osbourne"); + + const chatHistory2 = new FileSystemChatMessageHistory({ + sessionId: uuid(), + userId: "user1", + }); + const context2 = { title: "Best guitarist" }; + + await chatHistory2.addUserMessage("Who is the best guitarist?"); + await chatHistory2.addAIMessage("Jimi Hendrix"); + await chatHistory2.setContext(context2); + + const sessions = await chatHistory1.getAllSessions(); + + expect(sessions.length).toBe(2); + expect(sessions[0].context).toEqual(context1); + expect(sessions[1].context).toEqual(context2); +});