unsloth/studio/backend/routes/chat_history.py
Lee Jackson 61ed4cac51
Studio: persist chat history in backend storage (#5272)
* feat: Persist chat history in backend storage

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Address chat tombstone batching review

* fix: update desktop auth routes stub

* chat db settings storage

* chat db settings routes

* chat db settings client

* chat db settings store

* chat db settings wiring

* chat db history storage

* chat db settings migration

* chat db settings fallback

* chat db container metadata

* chat db legacy migration fixes

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* chat ci auth background reads

* chat auth storage fixes

* chat migration final fixes

* chat export batch message lookup

* chat history review fixes

* chat prune sync fix

* chat settings hydration retry

* gate settings persistence

* Scope chat-history rows by subject; fix hijack, clear-confirm, hydrate race

Backend storage and routes:
- chat_threads / chat_messages / chat_settings carry a NOT NULL subject
  column with composite PRIMARY KEY (id, subject). Two authenticated
  identities can no longer see or wipe each other's data.
- Pre-existing rows on an existing studio.db migrate under sentinel
  subject __legacy_unscoped__ via rename + rebuild + copy; single-user
  installs see no behavior change.
- ON CONFLICT(id, subject) DO UPDATE ... WHERE chat_messages.thread_id =
  excluded.thread_id refuses cross-thread re-parenting via upsert.
  upsert_chat_message + sync_chat_messages now raise
  ChatMessageThreadMismatch which the routes map to HTTP 409.
- replace_thread_messages rejects body messages whose threadId does not
  match the URL thread (HTTP 400) instead of silently rewriting them.
- DELETE /api/chat requires ?confirm=true, returns row count, logs the
  subject and count.
- upsert_chat_settings_merge does read + deep-merge + write inside a
  single BEGIN IMMEDIATE so concurrent writers no longer drop each
  other's updates. The route delegates to this helper.
- New POST /api/chat/messages:batch returns {thread_id -> messages[]}
  for many threads in one HTTP call. Subject-scoped. Unknown ids return
  empty lists instead of 404 so the sidebar/search caller can rebuild
  atomically.

Frontend:
- chat-runtime-store: hydrate-failure catch sets settingsHydrated:true
  so a transient backend blip no longer permanently disables
  persistence. setParams bumps inferenceParamMutationVersions
  unconditionally so a slow hydration response cannot clobber a
  pre-hydrate user edit. saveSettingsPatch replaces the serial chain
  with a debounced pendingPatch + deep merge; flush on beforeunload.
- chat-history-storage: clearStoredChats returns ClearStoredChatsResult
  distinguishing backend / legacy / both outcomes.
  listStoredChatThreadsWithMessages uses the batched fetch (one HTTP
  call) instead of Promise.all per-thread; legacy Dexie fallback only
  fires when the batch result is empty.
- chat-api: batchListChatMessages with graceful 404 / 405 fallback to
  per-thread listChatMessages for older servers.
- chat-thread-tombstones: store {id, deletedAt} tuples with 90-day GC
  and a 5000-entry cap so localStorage stays bounded. Back-compat reads
  pre-fix plain strings. Adds removeChatThreadTombstones (rollback) and
  clearAllChatThreadTombstones (post-legacy-purge clean-up).
- use-chat-sidebar-items: deleteChatItem tombstones synchronously
  BEFORE the backend round-trip and rolls back on failure (restores
  pre-PR optimistic UX). 300 ms trailing debounce on
  CHAT_HISTORY_UPDATED_EVENT plus requestSeq guard so stream-time event
  bursts produce at most one fetch per quiet window.

Tests:
- studio/backend/tests/pr5272_sim/ adds 64 regression tests covering
  schema migration from pre-fix shape, subject scoping, cross-thread
  hijack, bulk-replace mismatch, clear-confirm, concurrent settings,
  unicode + 2MB content + SQL-injection-safe binding, chunking
  boundary at 900 and 901 ids, batched endpoint (multi-subject + 1200
  ids + per-thread order), and grep contracts for the frontend patches.
  test_chat_history_storage.py updated to pass subject.

Verified locally on Linux + macOS + Windows GitHub Actions runners
(staging fork): 64 pass + 2 from the PR's own backend test on all
three OSes.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Drop subject scoping and clear-confirm gate (Studio is single-user)

Per maintainer feedback: subject scoping, cross-thread message hijack
guard, and DELETE /api/chat ?confirm=true gate are unnecessary because
Studio is intentionally single-user (the client already shows a confirm
dialog before clear-all).

This commit reverts those backend changes and keeps only the
non-multi-user pieces from the earlier fix commit:

- studio_db.py: restored to pre-fix shape; adds upsert_chat_settings_merge
  which does atomic read + deep-merge + write under BEGIN IMMEDIATE so
  two concurrent slider drags cannot drop one another's updates.
- routes/chat_history.py: restored; put_settings now calls the atomic
  merge instead of doing the read-merge-write across three separate
  connections. Adds POST /api/chat/messages:batch to collapse the
  sidebar/search rebuild from N round-trips to 1.
- frontend/api/chat-api.ts: align batchListChatMessages request and
  response keys with the backend (threadIds / messagesByThreadId).
- tests/test_chat_history_storage.py: add atomic-merge concurrency test,
  deep-merge nested-key test, and 901-id chunking-boundary test.
- Drop the pr5272_sim test directory (those tests covered the reverted
  subject-scoping/hijack/confirm behavior).

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix sidebar delete crash, keepalive on settings beforeunload flush, search rebuild race

Two correctness bugs and one perf race surfaced by a fresh code review of
the prior fix commit:

- chat-api.ts: notifyChatHistoryUpdated was declared as a non-exported
  function, but use-chat-sidebar-items.ts imports it. The import would
  fail tsc with TS2305 and at runtime the optimistic-delete and
  delete-failure rollback paths would both throw.
- chat-runtime-store.ts + chat-settings-api.ts + chat-settings-storage.ts:
  the beforeunload settings flush is now actually keepalive. Without it
  the browser cancels the in-flight PUT on tab close, so the last slider
  drag is silently dropped (which is exactly the case the
  debounce+beforeunload combination was meant to protect against).
- use-chat-search-index.ts: rebuilds now coalesce with a 300ms trailing
  debounce and discard out-of-order responses via a requestSeq guard.
  Matches the sibling pattern in use-chat-sidebar-items.ts so two rapid
  CHAT_HISTORY_UPDATED_EVENTs (run-start + run-end save during a turn)
  cannot land with stale data winning.
- chat-thread-tombstones.ts: drop dead clearAllChatThreadTombstones with
  no call sites; Dexie is never wiped so the function has no use.

* fix(studio): protect chat persistence writes

* fix(studio): align chat history clear semantics

* fix(studio): show partial chat clear feedback

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix(studio): preserve chat persistence fallbacks

* fix(studio): harden chat thread persistence checks

* Preserve chat message timestamps

* Gate chat stream on history save

* Make chat thread backfill best effort

* Avoid chat message 404 probe

* Tighten chat legacy fallbacks

* chat: server-side ledger so legacy Dexie import is recoverable

The boolean localStorage sentinel
(unsloth_chat_legacy_imported_to_studio_db) made importLegacyChatsIfNeeded
non-recoverable: deleting studio.db while the browser keeps the flag
silently hides every legacy Dexie thread from the sidebar (verified by
the 3-GPU validation probe; matches the third review comment on PR
#5272). Same trap fires for browser-profile sync to a fresh machine
and any other path that wipes studio.db while keeping IndexedDB.

Source of truth moves into studio.db itself via a new
chat_legacy_import_log table keyed by legacy thread id. The ledger
disappears together with studio.db, so the next launch re-runs the
import from whatever Dexie still holds. localStorage stays as a
per-session perf hint only.

Performance, all bounded by the three new fast-paths before any
backend work:

  A) localStorage hint says "imported earlier in this session" -- 0
     network, ~0 ms. Covers the warm sidebar mount.

  B) indexedDB.databases() reports no "unsloth-chat" DB -- 0 network,
     ~1 ms. Covers every new user who never had the old browser-only
     Studio (the common case after launch).

  C) db.threads.count() + db.messages.count() are both 0 -- 0 network,
     ~5 ms. Covers returning users who migrated long ago and Dexie was
     never repopulated.

Only when all three miss does the code talk to the backend
(GET /api/chat/import-ledger -> diff vs Dexie -> existing import path
-> POST /api/chat/import-ledger to record what was just imported).
Per-thread tracking is enough because Dexie is read-only after this
PR; a thread's message set does not grow.

Backend deployments that predate the import-ledger routes are
handled transparently: the client treats 404/405 as an empty ledger
and re-runs the (idempotent via UPSERT) import on next launch.

Changes:
- storage/studio_db.py: new chat_legacy_import_log table (WITHOUT
  ROWID, PK on legacy_thread_id) + list_chat_legacy_import_log() +
  record_chat_legacy_import_log() (idempotent batch UPSERT).
- routes/chat_history.py: GET + POST /api/chat/import-ledger with the
  obvious request/response models.
- frontend api/chat-api.ts: listChatImportLedger() (returns a Set for
  O(1) diff) + recordChatImportLedger(), both with 404/405 fallback.
- frontend utils/chat-history-storage.ts: importLegacyChatsIfNeeded
  gains three fast-paths, ledger fetch on the slow path, and writes
  the ledger after a successful import. The localStorage helper is
  unchanged on the surface; it just stops being authoritative.
- tests: 5 new test_legacy_import_log_* cases (empty default, record
  + list round-trip, idempotency, input dedup, empty/null ignore).
  All 9 pre-existing tests still pass.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Make the legacy-import recovery actually recoverable

The previous commit added a server-side ledger to make Dexie -> studio.db
import recoverable after a studio.db wipe, but the localStorage perf hint
still short-circuited the import gate before the ledger was ever consulted.
After a wipe, the hint stayed "true" and the bulk re-import never ran -- the
ledger sat empty and only the per-thread lazy materialize-on-continue path
restored data.

Changes:

- Remove the localStorage short-circuit from importLegacyChatsIfNeeded so
  the ledger is checked on every fresh tab. legacyChatImportPromise keeps
  the per-session cache; the hint now only matters for the listing paths.
- Batch the slow path: one db.messages.where().anyOf().toArray() and one
  batchListChatMessages() instead of 2N round-trips. At 1k threads this
  drops a multi-second blocking import to a single request pair.
- recordChatImportLedger returns {accepted, inserted, supported}. The
  localStorage hint is only flipped when supported is true, so old
  backends (404 / 405 / 501) no longer permanently poison recovery.
- Ledger backfill: threads already present in chat_threads but missing
  from the ledger now get added too, so old-FE-then-new-FE deployments
  don't redo the diff every launch.
- Backend response field renamed recorded -> {accepted, inserted}.
  accepted is the deduped non-empty input count; inserted is the rows
  actually new (via INSERT ... RETURNING). Bounded by Field(max_length=
  10_000) on the request payload.
- Storage helpers renamed: chat_legacy_import_log -> chat_legacy_imports,
  record_* -> upsert_* to match the existing noun/verb conventions.
- DEXIE_DB_NAME exported from db.ts; duplicate constant in
  chat-history-storage.ts removed.
- 3 new route-level tests for /api/chat/import-ledger covering the
  round-trip, the (accepted, inserted) split, and the 10k payload cap.

All 18 chat-history tests pass.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: shine1i <wasimysdev@gmail.com>
Co-authored-by: danielhanchen <michaelhan2050@gmail.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-05-22 06:18:05 -07:00

397 lines
13 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""
Chat history API routes backed by studio.db.
"""
from typing import Any, Literal, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from auth.authentication import get_current_subject
from storage.studio_db import (
ChatMessageConflictError,
CorruptSettingsError,
clear_chat_history,
count_chat_threads,
delete_chat_threads,
get_chat_thread,
get_chat_message,
list_chat_legacy_imports,
list_chat_settings,
list_chat_messages,
list_chat_messages_for_threads,
list_chat_threads,
sync_chat_messages,
update_chat_thread,
upsert_chat_legacy_imports,
upsert_chat_message,
upsert_chat_settings_merge,
upsert_chat_thread,
)
router = APIRouter()
class ChatThread(BaseModel):
id: str
title: str = "New Chat"
modelType: Literal["base", "lora", "model1", "model2"]
modelId: str = ""
pairId: Optional[str] = None
archived: bool = False
createdAt: int
openaiCodeExecContainerId: Optional[str] = None
anthropicCodeExecContainerId: Optional[str] = None
class ChatThreadPatch(BaseModel):
title: Optional[str] = None
modelType: Optional[Literal["base", "lora", "model1", "model2"]] = None
modelId: Optional[str] = None
pairId: Optional[str] = None
archived: Optional[bool] = None
createdAt: Optional[int] = None
openaiCodeExecContainerId: Optional[str] = None
anthropicCodeExecContainerId: Optional[str] = None
class ChatMessage(BaseModel):
id: str
threadId: str
parentId: Optional[str] = None
role: str
content: Any = Field(default_factory = list)
attachments: Optional[Any] = None
metadata: Optional[dict[str, Any]] = None
createdAt: int
class ChatThreadListResponse(BaseModel):
threads: list[ChatThread]
class ChatMessageListResponse(BaseModel):
messages: list[ChatMessage]
class ChatMessageSyncRequest(BaseModel):
messages: list[ChatMessage]
pruneMissing: bool = False
class ChatDeleteRequest(BaseModel):
ids: list[str]
class ChatCountResponse(BaseModel):
count: int
class ChatExportResponse(BaseModel):
exportedAt: str
version: int
threadCount: int
threads: list[ChatThread]
messages: list[ChatMessage]
class ChatInferenceSettings(BaseModel):
model_config = ConfigDict(extra = "forbid")
temperature: Optional[float] = None
topP: Optional[float] = None
topK: Optional[float] = None
minP: Optional[float] = None
repetitionPenalty: Optional[float] = None
presencePenalty: Optional[float] = None
maxSeqLength: Optional[float] = None
maxTokens: Optional[float] = None
systemPrompt: Optional[str] = None
trustRemoteCode: Optional[bool] = None
class ChatPreset(BaseModel):
model_config = ConfigDict(extra = "forbid")
name: str
params: ChatInferenceSettings
class ChatSettingsPayload(BaseModel):
model_config = ConfigDict(extra = "forbid")
inferenceParams: Optional[ChatInferenceSettings] = None
customPresets: Optional[list[ChatPreset]] = None
activePreset: Optional[str] = None
activePresetSource: Optional[Literal["builtin-default", "custom", "modified"]] = (
None
)
autoTitle: Optional[bool] = None
reasoningEffort: Optional[
Literal["none", "minimal", "low", "medium", "high", "max", "xhigh"]
] = None
preserveThinking: Optional[bool] = None
autoHealToolCalls: Optional[bool] = None
maxToolCallsPerMessage: Optional[int] = Field(default = None, ge = 1)
toolCallTimeout: Optional[int] = Field(default = None, ge = 1)
class ChatSettingsResponse(BaseModel):
settings: dict[str, Any]
class ChatMessagesBatchRequest(BaseModel):
threadIds: list[str]
class ChatMessagesBatchResponse(BaseModel):
messagesByThreadId: dict[str, list[ChatMessage]]
class ChatImportLedgerResponse(BaseModel):
# Plain list of legacy thread ids. Keeping the payload key-less keeps
# the client diff to a single Set construction.
threadIds: list[str]
class ChatImportLedgerRecordRequest(BaseModel):
# 10k cap keeps the request body bounded; real users have << 1k threads.
threadIds: list[str] = Field(default_factory = list, max_length = 10_000)
class ChatImportLedgerRecordResponse(BaseModel):
# accepted: deduped non-empty input count. inserted: rows actually new
# (ON CONFLICT DO NOTHING skips already-recorded ids). The client uses
# `accepted >= 0` as the "endpoint exists" signal and ignores the split
# otherwise.
accepted: int
inserted: int
@router.get("/threads", response_model = ChatThreadListResponse)
async def list_threads(
model_type: Optional[str] = Query(None),
pair_id: Optional[str] = Query(None),
include_archived: bool = Query(True),
current_subject: str = Depends(get_current_subject),
):
threads = list_chat_threads(
model_type = model_type,
pair_id = pair_id,
include_archived = include_archived,
)
return ChatThreadListResponse(threads = [ChatThread(**t) for t in threads])
@router.post("/threads", response_model = ChatThread)
async def save_thread(
payload: ChatThread,
current_subject: str = Depends(get_current_subject),
):
return ChatThread(**upsert_chat_thread(payload.model_dump()))
@router.get("/threads/{thread_id}", response_model = ChatThread)
async def get_thread(
thread_id: str,
current_subject: str = Depends(get_current_subject),
):
thread = get_chat_thread(thread_id)
if thread is None:
raise HTTPException(status_code = 404, detail = f"Thread {thread_id} not found")
return ChatThread(**thread)
@router.patch("/threads/{thread_id}", response_model = ChatThread)
async def patch_thread(
thread_id: str,
payload: ChatThreadPatch,
current_subject: str = Depends(get_current_subject),
):
patch = payload.model_dump(exclude_unset = True)
for field in ("title", "modelType", "modelId", "archived", "createdAt"):
if field in patch and patch[field] is None:
raise HTTPException(status_code = 400, detail = f"{field} cannot be null")
thread = update_chat_thread(
thread_id,
patch,
)
if thread is None:
raise HTTPException(status_code = 404, detail = f"Thread {thread_id} not found")
return ChatThread(**thread)
@router.delete("/threads")
async def delete_threads(
payload: ChatDeleteRequest,
current_subject: str = Depends(get_current_subject),
):
delete_chat_threads(payload.ids)
return {"status": "deleted"}
@router.get("/threads/{thread_id}/messages", response_model = ChatMessageListResponse)
async def get_thread_messages(
thread_id: str,
current_subject: str = Depends(get_current_subject),
):
if get_chat_thread(thread_id) is None:
raise HTTPException(status_code = 404, detail = f"Thread {thread_id} not found")
return ChatMessageListResponse(
messages = [ChatMessage(**m) for m in list_chat_messages(thread_id)]
)
@router.post("/messages:batch", response_model = ChatMessagesBatchResponse)
async def batch_thread_messages(
payload: ChatMessagesBatchRequest,
current_subject: str = Depends(get_current_subject),
):
"""One round-trip per sidebar/search rebuild instead of N. Unknown thread
ids are returned as empty lists so callers don't need a pre-flight."""
by_thread: dict[str, list[ChatMessage]] = {tid: [] for tid in payload.threadIds}
for m in list_chat_messages_for_threads(payload.threadIds):
tid = m["threadId"]
if tid in by_thread:
by_thread[tid].append(ChatMessage(**m))
return ChatMessagesBatchResponse(messagesByThreadId = by_thread)
@router.get("/threads/{thread_id}/messages/{message_id}", response_model = ChatMessage)
async def get_thread_message(
thread_id: str,
message_id: str,
current_subject: str = Depends(get_current_subject),
):
if get_chat_thread(thread_id) is None:
raise HTTPException(status_code = 404, detail = f"Thread {thread_id} not found")
message = get_chat_message(thread_id, message_id)
if message is None:
raise HTTPException(status_code = 404, detail = f"Message {message_id} not found")
return ChatMessage(**message)
@router.put("/threads/{thread_id}/messages/{message_id}", response_model = ChatMessage)
async def save_thread_message(
thread_id: str,
message_id: str,
payload: ChatMessage,
current_subject: str = Depends(get_current_subject),
):
if thread_id != payload.threadId or message_id != payload.id:
raise HTTPException(status_code = 400, detail = "Message id mismatch")
if get_chat_thread(thread_id) is None:
raise HTTPException(status_code = 404, detail = f"Thread {thread_id} not found")
try:
return ChatMessage(**upsert_chat_message(payload.model_dump()))
except ChatMessageConflictError as exc:
raise HTTPException(status_code = 409, detail = str(exc)) from exc
@router.put("/threads/{thread_id}/messages", response_model = ChatMessageListResponse)
async def replace_thread_messages(
thread_id: str,
payload: ChatMessageSyncRequest,
current_subject: str = Depends(get_current_subject),
):
mismatched_ids = [
message.id for message in payload.messages if message.threadId != thread_id
]
if mismatched_ids:
preview = ", ".join(mismatched_ids[:5])
suffix = (
"" if len(mismatched_ids) <= 5 else f" (+{len(mismatched_ids) - 5} more)"
)
raise HTTPException(
status_code = 400,
detail = f"Message threadId mismatch: {preview}{suffix}",
)
if get_chat_thread(thread_id) is None:
raise HTTPException(status_code = 404, detail = f"Thread {thread_id} not found")
messages = [message.model_dump() for message in payload.messages]
try:
return ChatMessageListResponse(
messages = [
ChatMessage(**m)
for m in sync_chat_messages(
thread_id,
messages,
prune_missing = payload.pruneMissing,
)
]
)
except ChatMessageConflictError as exc:
raise HTTPException(status_code = 409, detail = str(exc)) from exc
@router.get("/count", response_model = ChatCountResponse)
async def count_threads(current_subject: str = Depends(get_current_subject)):
return ChatCountResponse(count = count_chat_threads())
@router.get("/import-ledger", response_model = ChatImportLedgerResponse)
async def get_import_ledger(current_subject: str = Depends(get_current_subject)):
"""Legacy-Dexie import ledger. Returns the set of legacy thread ids
already copied into chat_threads / chat_messages. The frontend
uses this on every fresh tab open to decide whether to re-run the
Dexie -> studio.db import. Source of truth lives inside studio.db
so a studio.db wipe makes the import recoverable."""
return ChatImportLedgerResponse(threadIds = list_chat_legacy_imports())
@router.post("/import-ledger", response_model = ChatImportLedgerRecordResponse)
async def record_import_ledger(
payload: ChatImportLedgerRecordRequest,
current_subject: str = Depends(get_current_subject),
):
"""Mark each legacy thread id as imported. Idempotent."""
accepted, inserted = upsert_chat_legacy_imports(payload.threadIds)
return ChatImportLedgerRecordResponse(accepted = accepted, inserted = inserted)
@router.delete("")
async def clear_history(current_subject: str = Depends(get_current_subject)):
clear_chat_history()
return {"status": "deleted"}
@router.get("/settings", response_model = ChatSettingsResponse)
async def get_settings(current_subject: str = Depends(get_current_subject)):
return ChatSettingsResponse(settings = list_chat_settings())
@router.put("/settings", response_model = ChatSettingsResponse)
async def put_settings(
payload: dict[str, Any],
current_subject: str = Depends(get_current_subject),
):
try:
parsed = ChatSettingsPayload.model_validate(payload)
except ValidationError as exc:
raise HTTPException(status_code = 400, detail = exc.errors()) from exc
# Atomic read + deep-merge + write inside one BEGIN IMMEDIATE so two
# concurrent slider drags can't drop each other's updates.
try:
return ChatSettingsResponse(
settings = upsert_chat_settings_merge(parsed.model_dump(exclude_unset = True))
)
except CorruptSettingsError as exc:
raise HTTPException(status_code = 409, detail = str(exc)) from exc
@router.get("/export", response_model = ChatExportResponse)
async def export_history(current_subject: str = Depends(get_current_subject)):
from datetime import datetime, timezone
threads = list_chat_threads(include_archived = True)
messages = list_chat_messages_for_threads([thread["id"] for thread in threads])
return ChatExportResponse(
exportedAt = datetime.now(timezone.utc).isoformat(),
version = 1,
threadCount = len(threads),
threads = [ChatThread(**thread) for thread in threads],
messages = [ChatMessage(**message) for message in messages],
)