Skip to content

feat(history): add configurable chat history storage with MySQL persistence#334

Open
moyiliyi wants to merge 1 commit into
plmbr:mainfrom
moyiliyi:feat/support-optional-db
Open

feat(history): add configurable chat history storage with MySQL persistence#334
moyiliyi wants to merge 1 commit into
plmbr:mainfrom
moyiliyi:feat/support-optional-db

Conversation

@moyiliyi

Copy link
Copy Markdown
Contributor

Summary

This PR adds configurable chat history modes to Notebook Intelligence.

It introduces three explicit history modes:

  • none for ephemeral sessions with no restored history after refresh
  • local for local history with a configurable message limit
  • mysql for persistent server-side history

It also adds MySQL-backed storage for conversations, chat messages, and tool execution records, along with backend and frontend support for restoring history and listing recent conversations.

What changed

  • added explicit chat history modes:
    • none
    • local
    • mysql
  • added MySQL-backed storage for conversations, chat messages, and tool execution records
  • associated persisted conversations with user_id, chat_id, chat_mode, and conversation_id
  • used JUPYTERHUB_USER when available so persisted history can be scoped per user in JupyterHub-style deployments
  • added backend APIs for loading chat history and recent conversations
  • added settings UI for selecting history mode and configuring MySQL
  • updated the chat sidebar to restore history based on the selected history mode
  • updated chat history handling so ask and agent flows can continue from the same conversation history

Why

Before this change, chat history was effectively ephemeral: users could see messages in the current UI session, but history was lost on refresh.

This PR formalizes that behavior as none mode and adds two additional options:

  • local for lightweight history restoration
  • mysql for persistent, user-scoped history storage

If users do not configure MySQL, they can still use none or local mode.

The default mode is local.

If MySQL mode is selected but validation fails, the configuration is downgraded to none, and the user is notified in the settings UI.

Relation to #116

This PR mainly addresses the conversation storage and history persistence part of that discussion.

In MySQL mode, persisted conversations are associated with a user identity, using JUPYTERHUB_USER when available and otherwise falling back to the authenticated server user. That makes the storage model more suitable for multi-user JupyterHub environments than the previous ephemeral behavior.

UI Settings

none mode:
image

local mode:
image

mysql mode:
image

@moyiliyi moyiliyi marked this pull request as draft May 21, 2026 16:19
@moyiliyi moyiliyi force-pushed the feat/support-optional-db branch from 101ec47 to 3d4441e Compare May 25, 2026 13:12
@moyiliyi moyiliyi marked this pull request as ready for review May 25, 2026 13:21
@mbektas

mbektas commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

@moyiliyi can you rebase your branch, it seems to show more changes than expected in the diffs.

couple of comments.

  1. the local mode (in-memory storage) is the current default mode right?
  2. isn't the none mode problematic? LLMs need the chat history in the context
  3. I am concerned about making this specific to mySQL. shouldn't this be an extension point like LLM providers?

@pjdoland could you also review?

@moyiliyi moyiliyi marked this pull request as draft June 5, 2026 09:01
@moyiliyi moyiliyi force-pushed the feat/support-optional-db branch 2 times, most recently from d6a7d60 to 4122265 Compare June 12, 2026 08:41
@moyiliyi moyiliyi force-pushed the feat/support-optional-db branch from 4122265 to 3fb644f Compare June 13, 2026 08:02
@moyiliyi

Copy link
Copy Markdown
Contributor Author

Hi @mbektas ,

Thanks for the review. I rebased this branch and also refactored the implementation.

I think my previous PR description caused confusion, let me claifiy it :

History mode semantics

Before PR, the system had some short-lived runtime history during an active session, but chat history was still mostly implicit and weakly defined:

  • Browser refreshes often resulted in a "fresh" chat, losing context.
  • No clear distinction between transient runtime history and durable persisted history.
  • Agent/tool turns (user prompt, context, tool calls, results, and final response) were not captured as a single replayable unit, causing multi-turn or mixed Ask/Agent flows to lose context.

This PR makes those semantics explicit, so users and the codebase can reason about them consistently.

The history modes in this PR are:

  • persistent (Now display as: Persistent backend)
  • local (Now display as: Local temporary storage)
  • none (Now display as: Private session)
image

Mode comparison

User action persistent local private
Continue chatting without refresh (multi-turn Ask/Agent) Context continues Context continues Context continues
Browser refresh Restore from backend Restore from server memory when available; browser cache can help replay the visible transcript A fresh session starts
Server restart / kernel restart Restore from backend No restoration No restoration
Start "New chat session" Switches to a new chatId; old persisted records remain in backend Clears runtime transcript and starts a new chatId Starts a new session
Ask and Agent turns in the same sidebar session Same chatId, different turns can coexist Same Same
  • local is not only browser localStorage. The normal restore path still goes through /history; browser cache is only a fallback when that endpoint returns nothing or fails.
  • none (display name private session) does not mean every turn is isolated. It means the transcript is not restored after refresh.

For your comments

  1. the local mode (in-memory storage) is the current default mode right?

Not exactly. The previous default behavior was a bit of a hybrid.

Before this change, there was still a short in-memory history during the current live session. From the user's point of view after a browser refresh, the old behavior was much closer to what I now call Private session (I renamed none to make it less ambiguous), because the sidebar initialized with a new chatId and did not restore the previous transcript. In practice, after refresh the user would typically come back to a blank chat.

In this PR, the default is now local.
That means the same user in the same browser/storage scope can usually refresh, see the previous transcript, and continue the same chat.

  1. isn't the none mode problematic? LLMs need the chat history in the context

During the active session, messages are still accumulated in runtime history and sent back as context for later turns.
One important difference from the previous behavior is that, the live-session history path more explicit than before: normal chat requests now record the user turn, assistant response, and tool-related records under the same chatId. That makes multi-turn Agent chats, and also mixed Ask/Agent turns, flows more predictable while the session is alive.
I renamed none to Private session for less ambiguous. It is mainly say that it does not restore history after refresh; it does not mean that each turn becomes isolated while the current session is still active.

  1. I am concerned about making this specific to mySQL. shouldn't this be an extension point like LLM providers?

That is a very good point, and I agree. Based on that feedback, I refactored the history persistence layer so it is no longer MySQL-specific. The user-facing concept is now a persistent history mode, and the concrete storage backend is selected behind an extension point.
Current backends implemented in this PR:

  • sqlite
  • mysql

The abstraction lives in:

  • HistoryPersistenceBackend
  • HistoryPersistenceManager

The settings UI is driven from backend metadata, so it should be possible to add others later if that would be useful.

Implementation Details

1. Session model

The user-visible chat session in the sidebar is identified by chatId.
That means:

  • the same chatId is reused across turns in the same sidebar chat
  • Ask and Agent turns can belong to the same chatId
  • /history?chatId=... restores the transcript for that sidebar session
  • New chat session means: start a new chatId

This is the primary session boundary from the user's point of view.

Switching history storage starts a new chatId intentionally, so transcripts created under different storage semantics are not mixed into the same session.

2. Storage & Sanitization

I distinguish between three layers:

  • Runtime History: Active messages held in server memory for the current session.
  • Persisted History: Rich records stored by the selected persistent backend.
  • Replay/UI metadata: fields used to reconstruct what the user saw, but not always suitable to resend directly to an LLM provider

Runtime history

Runtime history is the live in-process history used while the current app/server process is alive.
In this PR:

  • normal chat requests explicitly record the user turn under the current session flow
  • assistant turns are recorded when the streamed response finishes
  • assistant runtime history can include reasoning_content, tool_calls, and ui_parts
  • tool result messages can also appear in runtime history during the active session
  • in local mode, /history reads from in-memory chat history, with frontend cache available only as a fallback replay path
  • in none mode, runtime history still exists for the active session, but /history does not restore it after refresh

Persisted history

When history_config.mode == persistent, the selected backend stores the turn in a richer form than plain runtime context.
Each persisted turn can include:

  • the user message
  • the assistant message
  • reasoning content when present
  • tool calls
  • tool result messages
  • ui_parts for replay
  • tool execution records
  • timestamps and user/chat scoping metadata

The currently implemented backends are:

  • sqlite
  • mysql

History is also scoped by resolved user_id, so the same chatId can be isolated per user in multi-user deployments.

Model context and UI replay

The replay transcript and the provider input history are intentionally not the same thing.
For later model turns, the provider should receive a provider-safe subset of history.
For replay, the UI needs richer data so restored Agent/tool turns look closer to what the user actually saw live.
That is why this PR stores richer fields such as:

  • tool_calls
  • tool messages
  • ui_parts
  • tool execution records

but sanitizes history before provider reuse:

  • ui_parts are removed before provider requests
  • malformed tool_calls are dropped
  • orphan tool messages are dropped
  • matched assistant tool-call blocks and matching tool-result messages can be preserved in a provider-safe form

That is the role of message_sanitizer.py and the provider call-site updates.

Future

A natural next step after this PR would be user-facing conversation management, such as browsing, resuming, and eventually deleting past chat sessions.

@mbektas @pjdoland I'd be grateful for any thoughts or feedback.

@moyiliyi moyiliyi marked this pull request as ready for review June 13, 2026 08:13
if reasoning is not None:
reasoning = str(reasoning)

tool_calls = None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not clear why this is still shown in this diff. it is from another commit. was the rebase done properly?

@mbektas mbektas requested a review from pjdoland June 16, 2026 03:01
@mbektas

mbektas commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

I don't think we should ship sqlite and mysql persistence implementations. they should be implemented in separate extensions. @pjdoland thoughts?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants