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>
This commit is contained in:
Lee Jackson 2026-05-22 14:18:05 +01:00 committed by GitHub
commit 61ed4cac51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 4674 additions and 1133 deletions

View file

@ -114,6 +114,7 @@ from datetime import datetime
# Import routers
from routes import (
auth_router,
chat_history_router,
data_recipe_router,
datasets_router,
export_router,
@ -367,6 +368,7 @@ _BODY_PROTECTED_PREFIXES = (
"/api/inference",
"/api/data-recipe",
"/api/datasets",
"/api/chat",
"/api/train",
"/api/export",
)
@ -509,6 +511,7 @@ app.add_middleware(
app.include_router(auth_router, prefix = "/api/auth", tags = ["auth"])
app.include_router(training_router, prefix = "/api/train", tags = ["training"])
app.include_router(models_router, prefix = "/api/models", tags = ["models"])
app.include_router(chat_history_router, prefix = "/api/chat", tags = ["chat"])
app.include_router(inference_router, prefix = "/api/inference", tags = ["inference"])
# Studio-only inference endpoints (cancel, etc.) are intentionally NOT
# exposed on the /v1 OpenAI-compat prefix below.

View file

@ -14,6 +14,7 @@ from routes.auth import router as auth_router
from routes.data_recipe import router as data_recipe_router
from routes.export import router as export_router
from routes.training_history import router as training_history_router
from routes.chat_history import router as chat_history_router
from routes.providers import router as providers_router
__all__ = [
@ -26,5 +27,6 @@ __all__ = [
"data_recipe_router",
"export_router",
"training_history_router",
"chat_history_router",
"providers_router",
]

View file

@ -0,0 +1,397 @@
# 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],
)

View file

@ -19,7 +19,7 @@ import threading
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
from typing import Optional
from typing import Any, Iterable, Optional
from utils.paths import studio_db_path, ensure_dir
@ -54,6 +54,7 @@ def _denied_path_prefixes() -> list[str]:
_schema_lock = threading.Lock()
_schema_ready = False
_SQLITE_IN_CHUNK_SIZE = 900
def _ensure_schema(conn: sqlite3.Connection) -> None:
@ -118,6 +119,92 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS chat_threads (
id TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL,
model_type TEXT NOT NULL,
model_id TEXT,
pair_id TEXT,
archived INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
openai_code_exec_container_id TEXT,
anthropic_code_exec_container_id TEXT
)
"""
)
chat_thread_cols = {
row[1] for row in conn.execute("PRAGMA table_info(chat_threads)").fetchall()
}
if "openai_code_exec_container_id" not in chat_thread_cols:
conn.execute(
"ALTER TABLE chat_threads ADD COLUMN openai_code_exec_container_id TEXT"
)
if "anthropic_code_exec_container_id" not in chat_thread_cols:
conn.execute(
"ALTER TABLE chat_threads ADD COLUMN anthropic_code_exec_container_id TEXT"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS chat_messages (
id TEXT NOT NULL PRIMARY KEY,
thread_id TEXT NOT NULL REFERENCES chat_threads(id) ON DELETE CASCADE,
parent_id TEXT,
role TEXT NOT NULL,
content_json TEXT NOT NULL,
attachments_json TEXT,
metadata_json TEXT,
created_at INTEGER NOT NULL
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_chat_threads_model_type_created_at ON chat_threads(model_type, created_at)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_chat_threads_pair_id ON chat_threads(pair_id)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_chat_messages_thread_id_created_at ON chat_messages(thread_id, created_at)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS chat_settings (
key TEXT NOT NULL PRIMARY KEY,
value_json TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS chat_settings_quarantine (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL,
value_json TEXT NOT NULL,
reason TEXT NOT NULL,
quarantined_at TEXT NOT NULL
)
"""
)
# Server-side import ledger so a studio.db wipe correctly re-triggers
# the legacy Dexie import. The previous boolean localStorage sentinel
# (`unsloth_chat_legacy_imported_to_studio_db`) is non-recoverable:
# if studio.db is recreated while the browser keeps the flag, legacy
# Dexie threads are silently hidden from the sidebar. The ledger
# lives inside studio.db so it disappears together with the data it
# is supposed to track, which is the recovery the boolean lacked.
# Keyed by legacy thread id; per-thread is sufficient because Dexie
# is read-only after this PR (a thread's message set does not grow).
conn.execute(
"""
CREATE TABLE IF NOT EXISTS chat_legacy_imports (
legacy_thread_id TEXT NOT NULL PRIMARY KEY,
imported_at INTEGER NOT NULL
) WITHOUT ROWID
"""
)
def get_connection() -> sqlite3.Connection:
@ -575,3 +662,578 @@ def remove_scan_folder(id: int) -> None:
conn.commit()
finally:
conn.close()
def _json_loads(value: str | None, fallback):
if value is None:
return fallback
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError):
return fallback
def _chat_thread_from_row(row: sqlite3.Row) -> dict:
data = dict(row)
return {
"id": data["id"],
"title": data["title"],
"modelType": data["model_type"],
"modelId": data.get("model_id") or "",
"pairId": data.get("pair_id") or None,
"archived": bool(data["archived"]),
"createdAt": data["created_at"],
"openaiCodeExecContainerId": data.get("openai_code_exec_container_id"),
"anthropicCodeExecContainerId": data.get("anthropic_code_exec_container_id"),
}
def _chat_message_from_row(row: sqlite3.Row) -> dict:
data = dict(row)
message = {
"id": data["id"],
"threadId": data["thread_id"],
"parentId": data.get("parent_id"),
"role": data["role"],
"content": _json_loads(data.get("content_json"), []),
"createdAt": data["created_at"],
}
attachments = _json_loads(data.get("attachments_json"), None)
metadata = _json_loads(data.get("metadata_json"), None)
if attachments is not None:
message["attachments"] = attachments
if metadata is not None:
message["metadata"] = metadata
return message
def upsert_chat_thread(thread: dict) -> dict:
conn = get_connection()
try:
conn.execute(
"""
INSERT INTO chat_threads
(id, title, model_type, model_id, pair_id, archived, created_at, openai_code_exec_container_id, anthropic_code_exec_container_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
model_type = excluded.model_type,
model_id = excluded.model_id,
pair_id = excluded.pair_id,
archived = excluded.archived,
created_at = excluded.created_at,
openai_code_exec_container_id = excluded.openai_code_exec_container_id,
anthropic_code_exec_container_id = excluded.anthropic_code_exec_container_id
""",
(
thread["id"],
thread.get("title") or "New Chat",
thread["modelType"],
thread.get("modelId") or "",
thread.get("pairId"),
1 if thread.get("archived") else 0,
int(thread["createdAt"]),
thread.get("openaiCodeExecContainerId"),
thread.get("anthropicCodeExecContainerId"),
),
)
conn.commit()
return get_chat_thread(thread["id"]) or thread
finally:
conn.close()
def update_chat_thread(id: str, patch: dict) -> Optional[dict]:
allowed = {
"title": ("title", patch.get("title")),
"modelType": ("model_type", patch.get("modelType")),
"modelId": ("model_id", patch.get("modelId")),
"pairId": ("pair_id", patch.get("pairId")),
"archived": ("archived", 1 if patch.get("archived") else 0),
"createdAt": ("created_at", patch.get("createdAt")),
"openaiCodeExecContainerId": (
"openai_code_exec_container_id",
patch.get("openaiCodeExecContainerId"),
),
"anthropicCodeExecContainerId": (
"anthropic_code_exec_container_id",
patch.get("anthropicCodeExecContainerId"),
),
}
assignments = []
values = []
for key, (column, value) in allowed.items():
if key in patch:
assignments.append(f"{column} = ?")
values.append(value)
if not assignments:
return get_chat_thread(id)
conn = get_connection()
try:
conn.execute(
f"UPDATE chat_threads SET {', '.join(assignments)} WHERE id = ?",
(*values, id),
)
conn.commit()
row = conn.execute("SELECT * FROM chat_threads WHERE id = ?", (id,)).fetchone()
return _chat_thread_from_row(row) if row is not None else None
finally:
conn.close()
def get_chat_thread(id: str) -> Optional[dict]:
conn = get_connection()
try:
row = conn.execute("SELECT * FROM chat_threads WHERE id = ?", (id,)).fetchone()
return _chat_thread_from_row(row) if row is not None else None
finally:
conn.close()
def list_chat_threads(
model_type: str | None = None,
pair_id: str | None = None,
include_archived: bool = True,
) -> list[dict]:
clauses = []
values: list[object] = []
if model_type is not None:
clauses.append("model_type = ?")
values.append(model_type)
if pair_id is not None:
clauses.append("pair_id = ?")
values.append(pair_id)
if not include_archived:
clauses.append("archived = 0")
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
conn = get_connection()
try:
rows = conn.execute(
f"SELECT * FROM chat_threads {where} ORDER BY created_at DESC",
values,
).fetchall()
return [_chat_thread_from_row(row) for row in rows]
finally:
conn.close()
def delete_chat_threads(ids: list[str]) -> None:
if not ids:
return
conn = get_connection()
try:
conn.executemany("DELETE FROM chat_threads WHERE id = ?", [(id,) for id in ids])
conn.commit()
finally:
conn.close()
def clear_chat_history() -> None:
conn = get_connection()
try:
conn.execute("DELETE FROM chat_threads")
conn.commit()
finally:
conn.close()
def count_chat_threads() -> int:
conn = get_connection()
try:
return int(conn.execute("SELECT COUNT(*) FROM chat_threads").fetchone()[0])
finally:
conn.close()
class ChatMessageConflictError(RuntimeError):
"""Raised when a chat message id already belongs to another thread."""
class CorruptSettingsError(RuntimeError):
"""Raised when a partial settings patch would overwrite corrupt settings."""
def _parse_chat_setting_json(key: str, value_json: str) -> tuple[bool, Any]:
try:
return True, json.loads(value_json)
except (json.JSONDecodeError, TypeError) as exc:
logger.warning(
"Corrupt chat_settings JSON; quarantining key=%s error=%s",
key,
exc,
)
return False, None
def _load_chat_settings_for_merge(
conn: sqlite3.Connection,
) -> tuple[dict[str, Any], set[str]]:
rows = conn.execute("SELECT key, value_json FROM chat_settings").fetchall()
current: dict[str, Any] = {}
corrupt: set[str] = set()
now = datetime.now(timezone.utc).isoformat()
for row in rows:
ok, value = _parse_chat_setting_json(row["key"], row["value_json"])
if ok:
current[row["key"]] = value
continue
corrupt.add(row["key"])
conn.execute(
"""
INSERT INTO chat_settings_quarantine
(key, value_json, reason, quarantined_at)
VALUES (?, ?, ?, ?)
""",
(row["key"], row["value_json"], "json_decode_error", now),
)
conn.execute(
"DELETE FROM chat_settings WHERE key = ? AND value_json = ?",
(row["key"], row["value_json"]),
)
return current, corrupt
def _raise_if_chat_message_thread_conflicts(
conn: sqlite3.Connection,
thread_id: str,
message_ids: list[str],
) -> None:
unique_ids = list(dict.fromkeys(message_ids))
if not unique_ids:
return
conflicts: list[str] = []
for start in range(0, len(unique_ids), _SQLITE_IN_CHUNK_SIZE):
chunk = unique_ids[start : start + _SQLITE_IN_CHUNK_SIZE]
placeholders = ",".join("?" for _ in chunk)
rows = conn.execute(
f"""
SELECT id FROM chat_messages
WHERE id IN ({placeholders}) AND thread_id != ?
ORDER BY id
""",
(*chunk, thread_id),
).fetchall()
conflicts.extend(row["id"] for row in rows)
if conflicts:
preview = ", ".join(conflicts[:5])
suffix = "" if len(conflicts) <= 5 else f" (+{len(conflicts) - 5} more)"
raise ChatMessageConflictError(
f"Message id already belongs to another thread: {preview}{suffix}"
)
def upsert_chat_message(message: dict) -> dict:
conn = get_connection()
try:
conn.execute("BEGIN IMMEDIATE")
_raise_if_chat_message_thread_conflicts(
conn,
message["threadId"],
[message["id"]],
)
conn.execute(
"""
INSERT INTO chat_messages
(id, thread_id, parent_id, role, content_json, attachments_json, metadata_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
parent_id = excluded.parent_id,
role = excluded.role,
content_json = excluded.content_json,
attachments_json = excluded.attachments_json,
metadata_json = excluded.metadata_json,
created_at = excluded.created_at
WHERE excluded.thread_id = chat_messages.thread_id
""",
(
message["id"],
message["threadId"],
message.get("parentId"),
message["role"],
json.dumps(message.get("content", [])),
json.dumps(message.get("attachments"))
if message.get("attachments") is not None
else None,
json.dumps(message.get("metadata"))
if message.get("metadata") is not None
else None,
int(message["createdAt"]),
),
)
conn.commit()
return message
except Exception:
conn.rollback()
raise
finally:
conn.close()
def sync_chat_messages(
thread_id: str,
messages: list[dict],
prune_missing: bool = False,
) -> list[dict]:
conn = get_connection()
try:
conn.execute("BEGIN IMMEDIATE")
_raise_if_chat_message_thread_conflicts(
conn,
thread_id,
[m["id"] for m in messages],
)
if prune_missing:
conn.execute("DELETE FROM chat_messages WHERE thread_id = ?", (thread_id,))
conn.executemany(
"""
INSERT INTO chat_messages
(id, thread_id, parent_id, role, content_json, attachments_json, metadata_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
parent_id = excluded.parent_id,
role = excluded.role,
content_json = excluded.content_json,
attachments_json = excluded.attachments_json,
metadata_json = excluded.metadata_json,
created_at = excluded.created_at
WHERE excluded.thread_id = chat_messages.thread_id
""",
[
(
m["id"],
thread_id,
m.get("parentId"),
m["role"],
json.dumps(m.get("content", [])),
json.dumps(m.get("attachments"))
if m.get("attachments") is not None
else None,
json.dumps(m.get("metadata"))
if m.get("metadata") is not None
else None,
int(m["createdAt"]),
)
for m in messages
],
)
conn.commit()
return list_chat_messages(thread_id)
except ChatMessageConflictError:
conn.rollback()
raise
except sqlite3.Error:
logger.exception("Failed to sync chat messages for thread %s", thread_id)
conn.rollback()
raise
finally:
conn.close()
def list_chat_messages(thread_id: str) -> list[dict]:
conn = get_connection()
try:
rows = conn.execute(
"""
SELECT * FROM chat_messages
WHERE thread_id = ?
ORDER BY created_at ASC, id ASC
""",
(thread_id,),
).fetchall()
return [_chat_message_from_row(row) for row in rows]
finally:
conn.close()
def get_chat_message(thread_id: str, message_id: str) -> Optional[dict]:
conn = get_connection()
try:
row = conn.execute(
"""
SELECT * FROM chat_messages
WHERE thread_id = ? AND id = ?
""",
(thread_id, message_id),
).fetchone()
return _chat_message_from_row(row) if row is not None else None
finally:
conn.close()
def list_chat_messages_for_threads(thread_ids: list[str]) -> list[dict]:
if not thread_ids:
return []
unique_thread_ids = list(dict.fromkeys(thread_ids))
messages: list[dict] = []
conn = get_connection()
try:
for start in range(0, len(unique_thread_ids), _SQLITE_IN_CHUNK_SIZE):
chunk = unique_thread_ids[start : start + _SQLITE_IN_CHUNK_SIZE]
placeholders = ",".join("?" for _ in chunk)
rows = conn.execute(
f"""
SELECT * FROM chat_messages
WHERE thread_id IN ({placeholders})
ORDER BY created_at ASC, id ASC
""",
chunk,
).fetchall()
messages.extend(_chat_message_from_row(row) for row in rows)
return sorted(
messages,
key = lambda message: (message["createdAt"], message["id"]),
)
finally:
conn.close()
def list_chat_settings() -> dict[str, Any]:
conn = get_connection()
try:
rows = conn.execute(
"SELECT key, value_json FROM chat_settings ORDER BY key"
).fetchall()
settings: dict[str, Any] = {}
for row in rows:
settings[row["key"]] = _json_loads(row["value_json"], None)
return settings
finally:
conn.close()
def upsert_chat_settings(settings: dict[str, Any]) -> dict[str, Any]:
if not settings:
return list_chat_settings()
conn = get_connection()
try:
now = datetime.now(timezone.utc).isoformat()
conn.executemany(
"""
INSERT INTO chat_settings (key, value_json, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET
value_json = excluded.value_json,
updated_at = excluded.updated_at
""",
[(key, json.dumps(value), now) for key, value in settings.items()],
)
conn.commit()
return list_chat_settings()
finally:
conn.close()
def _deep_merge_settings(
current: dict[str, Any], updates: dict[str, Any]
) -> dict[str, Any]:
merged = dict(current)
for key, value in updates.items():
current_value = merged.get(key)
if isinstance(current_value, dict) and isinstance(value, dict):
merged[key] = _deep_merge_settings(current_value, value)
else:
merged[key] = value
return merged
def upsert_chat_settings_merge(updates: dict[str, Any]) -> dict[str, Any]:
"""Atomic read-merge-write under BEGIN IMMEDIATE so two concurrent writers
cannot drop one another's updates."""
if not updates:
return list_chat_settings()
conn = get_connection()
try:
conn.execute("BEGIN IMMEDIATE")
current, corrupt = _load_chat_settings_for_merge(conn)
unsafe_partial_keys = [
key
for key, value in updates.items()
if key in corrupt and isinstance(value, dict)
]
if unsafe_partial_keys:
conn.commit()
keys = ", ".join(sorted(unsafe_partial_keys))
raise CorruptSettingsError(
f"Cannot apply partial settings patch to corrupt key(s): {keys}"
)
merged = _deep_merge_settings(current, updates)
now = datetime.now(timezone.utc).isoformat()
conn.executemany(
"""
INSERT INTO chat_settings (key, value_json, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET
value_json = excluded.value_json,
updated_at = excluded.updated_at
""",
[(key, json.dumps(value), now) for key, value in merged.items()],
)
conn.commit()
return merged
except CorruptSettingsError:
raise
except Exception:
conn.rollback()
raise
finally:
conn.close()
# ---------------------------------------------------------------------------
# Legacy Dexie import ledger
# ---------------------------------------------------------------------------
# See the schema comment in _ensure_schema() for the recovery rationale.
def list_chat_legacy_imports() -> list[str]:
"""Return the legacy_thread_id of every thread already imported.
Cheap: scans a single small PK-only table. The frontend stuffs the
result into a Set before walking Dexie, so the diff is O(|Dexie|).
"""
conn = get_connection()
try:
rows = conn.execute(
"SELECT legacy_thread_id FROM chat_legacy_imports"
).fetchall()
return [row[0] for row in rows]
finally:
conn.close()
def upsert_chat_legacy_imports(legacy_thread_ids: list[str]) -> tuple[int, int]:
"""Mark each given legacy thread id as imported. Idempotent.
Returns (accepted, inserted):
- accepted: number of non-empty deduped input ids
- inserted: number of rows that were actually new (not already in ledger)
ON CONFLICT DO NOTHING keeps the existing imported_at when an id is
recorded twice. INSERT...RETURNING reports only the rows that were
actually inserted, so callers can distinguish first-time imports
from idempotent re-runs without an extra SELECT.
"""
ids = list(dict.fromkeys(tid for tid in legacy_thread_ids if tid))
if not ids:
return 0, 0
ts = int(datetime.now(timezone.utc).timestamp() * 1000)
conn = get_connection()
try:
inserted = 0
for tid in ids:
row = conn.execute(
"""
INSERT INTO chat_legacy_imports (legacy_thread_id, imported_at)
VALUES (?, ?)
ON CONFLICT(legacy_thread_id) DO NOTHING
RETURNING legacy_thread_id
""",
(tid, ts),
).fetchone()
if row is not None:
inserted += 1
conn.commit()
return len(ids), inserted
finally:
conn.close()

View file

@ -0,0 +1,109 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import asyncio
import os
import sys
import pytest
from fastapi import HTTPException
_backend = os.path.join(os.path.dirname(__file__), "..")
sys.path.insert(0, _backend)
from routes import chat_history
def _message(message_id: str, thread_id: str) -> chat_history.ChatMessage:
return chat_history.ChatMessage(
id = message_id,
threadId = thread_id,
parentId = None,
role = "user",
content = [{"type": "text", "text": "hello"}],
createdAt = 1_700_000_000_000,
)
def test_replace_thread_messages_rejects_body_thread_mismatch(monkeypatch):
called = False
def fake_get_chat_thread(thread_id: str):
return {"id": thread_id}
def fake_sync_chat_messages(*args, **kwargs):
nonlocal called
called = True
return []
monkeypatch.setattr(chat_history, "get_chat_thread", fake_get_chat_thread)
monkeypatch.setattr(chat_history, "sync_chat_messages", fake_sync_chat_messages)
with pytest.raises(HTTPException) as exc_info:
asyncio.run(
chat_history.replace_thread_messages(
"thread-1",
chat_history.ChatMessageSyncRequest(
messages = [_message("msg-1", "thread-2")],
pruneMissing = True,
),
current_subject = "test-user",
)
)
assert exc_info.value.status_code == 400
assert "Message threadId mismatch" in str(exc_info.value.detail)
assert called is False
# ---------------------------------------------------------------------------
# /api/chat/import-ledger
# ---------------------------------------------------------------------------
def test_get_import_ledger_round_trips_through_storage(monkeypatch):
seen: list[str] = []
def fake_list():
return list(seen)
monkeypatch.setattr(chat_history, "list_chat_legacy_imports", fake_list)
response = asyncio.run(chat_history.get_import_ledger(current_subject = "test-user"))
assert response.threadIds == []
seen.extend(["legacy-a", "legacy-b"])
response = asyncio.run(chat_history.get_import_ledger(current_subject = "test-user"))
assert response.threadIds == ["legacy-a", "legacy-b"]
def test_record_import_ledger_returns_accepted_and_inserted(monkeypatch):
captured: list[list[str]] = []
def fake_upsert(thread_ids):
captured.append(list(thread_ids))
# Pretend two of the three were already in the ledger.
return (len(thread_ids), max(0, len(thread_ids) - 2))
monkeypatch.setattr(chat_history, "upsert_chat_legacy_imports", fake_upsert)
response = asyncio.run(
chat_history.record_import_ledger(
payload = chat_history.ChatImportLedgerRecordRequest(
threadIds = ["a", "b", "c"],
),
current_subject = "test-user",
)
)
assert response.accepted == 3
assert response.inserted == 1
assert captured == [["a", "b", "c"]]
def test_record_import_ledger_rejects_oversize_payload():
from pydantic import ValidationError
with pytest.raises(ValidationError):
chat_history.ChatImportLedgerRecordRequest(
threadIds = [f"id-{i}" for i in range(10_001)],
)

View file

@ -0,0 +1,308 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import threading
import pytest
from storage import studio_db
def _reset_studio_db(tmp_path, monkeypatch):
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
monkeypatch.setattr(studio_db, "_schema_ready", False)
def _thread(thread_id: str = "thread-1") -> dict:
return {
"id": thread_id,
"title": "Test Chat",
"modelType": "base",
"modelId": "test-model",
"pairId": None,
"archived": False,
"createdAt": 1_700_000_000_000,
}
def _message(
message_id: str,
created_at: int,
content: str,
thread_id: str = "thread-1",
) -> dict:
return {
"id": message_id,
"threadId": thread_id,
"parentId": None,
"role": "user",
"content": [{"type": "text", "text": content}],
"createdAt": created_at,
}
def test_sync_chat_messages_upserts_without_pruning(tmp_path, monkeypatch):
_reset_studio_db(tmp_path, monkeypatch)
studio_db.upsert_chat_thread(_thread())
studio_db.sync_chat_messages(
"thread-1",
[
_message("msg-1", 1, "keep me"),
_message("msg-2", 2, "old text"),
],
prune_missing = True,
)
messages = studio_db.sync_chat_messages(
"thread-1",
[_message("msg-2", 2, "updated text")],
)
by_id = {message["id"]: message for message in messages}
assert set(by_id) == {"msg-1", "msg-2"}
assert by_id["msg-2"]["content"] == [{"type": "text", "text": "updated text"}]
def test_sync_chat_messages_prunes_when_requested(tmp_path, monkeypatch):
_reset_studio_db(tmp_path, monkeypatch)
studio_db.upsert_chat_thread(_thread())
studio_db.sync_chat_messages(
"thread-1",
[
_message("msg-1", 1, "delete me"),
_message("msg-2", 2, "keep me"),
],
)
messages = studio_db.sync_chat_messages(
"thread-1",
[_message("msg-2", 2, "keep me")],
prune_missing = True,
)
assert [message["id"] for message in messages] == ["msg-2"]
def test_upsert_chat_message_rejects_cross_thread_id_conflict(tmp_path, monkeypatch):
_reset_studio_db(tmp_path, monkeypatch)
studio_db.upsert_chat_thread(_thread("thread-1"))
studio_db.upsert_chat_thread(_thread("thread-2"))
studio_db.upsert_chat_message(_message("msg-1", 1, "original", "thread-1"))
with pytest.raises(studio_db.ChatMessageConflictError):
studio_db.upsert_chat_message(_message("msg-1", 2, "moved", "thread-2"))
assert [m["id"] for m in studio_db.list_chat_messages("thread-1")] == ["msg-1"]
assert studio_db.list_chat_messages("thread-2") == []
def test_sync_chat_messages_detects_conflict_before_prune(tmp_path, monkeypatch):
_reset_studio_db(tmp_path, monkeypatch)
studio_db.upsert_chat_thread(_thread("thread-1"))
studio_db.upsert_chat_thread(_thread("thread-2"))
studio_db.sync_chat_messages(
"thread-1",
[_message("keep-me", 1, "keep", "thread-1")],
)
studio_db.upsert_chat_message(_message("conflict", 2, "other", "thread-2"))
with pytest.raises(studio_db.ChatMessageConflictError):
studio_db.sync_chat_messages(
"thread-1",
[_message("conflict", 3, "bad", "thread-1")],
prune_missing = True,
)
assert [m["id"] for m in studio_db.list_chat_messages("thread-1")] == ["keep-me"]
assert [m["id"] for m in studio_db.list_chat_messages("thread-2")] == ["conflict"]
def test_settings_merge_atomic_under_concurrency(tmp_path, monkeypatch):
"""Two threads writing distinct keys must not drop each other's update."""
_reset_studio_db(tmp_path, monkeypatch)
studio_db.upsert_chat_settings_merge({"inferenceParams": {}})
barrier = threading.Barrier(2)
def writer(key: str, value: float) -> None:
barrier.wait()
studio_db.upsert_chat_settings_merge({"inferenceParams": {key: value}})
t1 = threading.Thread(target = writer, args = ("temperature", 0.7))
t2 = threading.Thread(target = writer, args = ("topP", 0.9))
t1.start()
t2.start()
t1.join()
t2.join()
merged = studio_db.list_chat_settings()["inferenceParams"]
assert merged.get("temperature") == 0.7
assert merged.get("topP") == 0.9
def test_settings_merge_preserves_nested_keys(tmp_path, monkeypatch):
_reset_studio_db(tmp_path, monkeypatch)
studio_db.upsert_chat_settings_merge(
{"inferenceParams": {"temperature": 0.5, "topP": 0.8}}
)
studio_db.upsert_chat_settings_merge({"inferenceParams": {"temperature": 0.9}})
params = studio_db.list_chat_settings()["inferenceParams"]
assert params == {"temperature": 0.9, "topP": 0.8}
def test_settings_merge_quarantines_corrupt_json_and_rejects_partial_patch(
tmp_path,
monkeypatch,
):
_reset_studio_db(tmp_path, monkeypatch)
studio_db.upsert_chat_settings_merge(
{"inferenceParams": {"temperature": 0.5, "topP": 0.8}}
)
conn = studio_db.get_connection()
try:
conn.execute(
"UPDATE chat_settings SET value_json = ? WHERE key = ?",
('{"temperature": 0.5', "inferenceParams"),
)
conn.commit()
finally:
conn.close()
with pytest.raises(studio_db.CorruptSettingsError):
studio_db.upsert_chat_settings_merge({"inferenceParams": {"temperature": 0.9}})
conn = studio_db.get_connection()
try:
quarantined = conn.execute(
"SELECT key, value_json, reason FROM chat_settings_quarantine"
).fetchall()
remaining = conn.execute(
"SELECT key FROM chat_settings WHERE key = ?",
("inferenceParams",),
).fetchall()
finally:
conn.close()
assert [row["key"] for row in quarantined] == ["inferenceParams"]
assert quarantined[0]["reason"] == "json_decode_error"
assert remaining == []
def test_settings_merge_replaces_corrupt_scalar_after_quarantine(tmp_path, monkeypatch):
_reset_studio_db(tmp_path, monkeypatch)
studio_db.upsert_chat_settings_merge({"autoTitle": False})
conn = studio_db.get_connection()
try:
conn.execute(
"UPDATE chat_settings SET value_json = ? WHERE key = ?",
("not-json", "autoTitle"),
)
conn.commit()
finally:
conn.close()
settings = studio_db.upsert_chat_settings_merge({"autoTitle": True})
assert settings["autoTitle"] is True
conn = studio_db.get_connection()
try:
quarantined = conn.execute(
"SELECT key, reason FROM chat_settings_quarantine"
).fetchall()
finally:
conn.close()
assert [(row["key"], row["reason"]) for row in quarantined] == [
("autoTitle", "json_decode_error")
]
def test_list_chat_messages_for_threads_chunks_over_900_ids(tmp_path, monkeypatch):
"""SQLite host-parameter limit is 999 on older builds; chunk at 900."""
_reset_studio_db(tmp_path, monkeypatch)
n = 901
for i in range(n):
studio_db.upsert_chat_thread(
{
"id": f"t-{i}",
"title": "T",
"modelType": "base",
"modelId": "m",
"pairId": None,
"archived": False,
"createdAt": 1_700_000_000_000 + i,
}
)
studio_db.upsert_chat_message(
{
"id": f"m-{i}",
"threadId": f"t-{i}",
"parentId": None,
"role": "user",
"content": [{"type": "text", "text": "hi"}],
"createdAt": 1_700_000_000_000 + i,
}
)
out = studio_db.list_chat_messages_for_threads([f"t-{i}" for i in range(n)])
assert len(out) == n
assert {m["threadId"] for m in out} == {f"t-{i}" for i in range(n)}
# ---------------------------------------------------------------------------
# Legacy Dexie import ledger
# ---------------------------------------------------------------------------
def test_legacy_imports_empty_by_default(tmp_path, monkeypatch):
_reset_studio_db(tmp_path, monkeypatch)
assert studio_db.list_chat_legacy_imports() == []
def test_legacy_imports_records_and_lists(tmp_path, monkeypatch):
_reset_studio_db(tmp_path, monkeypatch)
accepted, inserted = studio_db.upsert_chat_legacy_imports(
["legacy-a", "legacy-b", "legacy-c"],
)
assert accepted == 3
assert inserted == 3
assert set(studio_db.list_chat_legacy_imports()) == {
"legacy-a",
"legacy-b",
"legacy-c",
}
def test_legacy_imports_is_idempotent(tmp_path, monkeypatch):
_reset_studio_db(tmp_path, monkeypatch)
accepted1, inserted1 = studio_db.upsert_chat_legacy_imports(
["legacy-a", "legacy-b"],
)
accepted2, inserted2 = studio_db.upsert_chat_legacy_imports(
["legacy-b", "legacy-c"],
)
assert (accepted1, inserted1) == (2, 2)
# legacy-b is already in the ledger, only legacy-c is genuinely new.
assert (accepted2, inserted2) == (2, 1)
assert set(studio_db.list_chat_legacy_imports()) == {
"legacy-a",
"legacy-b",
"legacy-c",
}
def test_legacy_imports_dedups_input(tmp_path, monkeypatch):
_reset_studio_db(tmp_path, monkeypatch)
accepted, inserted = studio_db.upsert_chat_legacy_imports(
["x", "x", "y", "x"],
)
# accepted is the deduped non-empty input size; inserted is the rows
# actually new in the ledger after ON CONFLICT DO NOTHING.
assert accepted == 2
assert inserted == 2
assert set(studio_db.list_chat_legacy_imports()) == {"x", "y"}
def test_legacy_imports_ignores_empty(tmp_path, monkeypatch):
_reset_studio_db(tmp_path, monkeypatch)
assert studio_db.upsert_chat_legacy_imports([]) == (0, 0)
assert studio_db.upsert_chat_legacy_imports(["", None]) == (0, 0) # type: ignore[list-item]
assert studio_db.list_chat_legacy_imports() == []

View file

@ -431,6 +431,7 @@ def test_desktop_capabilities_json_reports_rollout_safe_flags():
def test_health_response_reports_desktop_capability_fields(monkeypatch):
router_stub = SimpleNamespace(
auth_router = APIRouter(),
chat_history_router = APIRouter(),
data_recipe_router = APIRouter(),
datasets_router = APIRouter(),
export_router = APIRouter(),

View file

@ -18,6 +18,9 @@ type RefreshResponse = {
};
let isRedirecting = false;
let refreshInflight: Promise<boolean> | null = null;
let refreshInflightToken: string | null = null;
let logoutGeneration = 0;
const TAURI_FETCH_RETRY_DELAYS_MS = [250, 750, 1500] as const;
@ -25,6 +28,10 @@ function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function clearAuthTokensIfCurrent(refreshToken: string | null): void {
if (!refreshToken || getRefreshToken() === refreshToken) clearAuthTokens();
}
async function fetchWithTauriNetworkRetry(
input: RequestInfo | URL,
init?: RequestInit,
@ -98,19 +105,15 @@ async function retryWithTauriAutoAuth(
return null;
}
// Singleflight: the backend consumes the refresh token atomically, so
// concurrent callers must share one in-flight promise (loser would 401).
let refreshInflight: Promise<boolean> | null = null;
// Bumped by logout(); a refresh that resolves after logout drops its
// new tokens instead of silently re-auth-ing the SPA.
let logoutGeneration = 0;
export async function refreshSession(): Promise<boolean> {
if (refreshInflight) return refreshInflight;
const refreshToken = getRefreshToken();
if (!refreshToken) return false;
if (refreshInflight && refreshInflightToken === refreshToken) {
return refreshInflight;
}
const startGeneration = logoutGeneration;
refreshInflight = (async () => {
const refreshToken = getRefreshToken();
if (!refreshToken) return false;
const promise = (async () => {
try {
const response = await fetchWithTauriNetworkRetry(
apiUrl("/api/auth/refresh"),
@ -121,11 +124,12 @@ export async function refreshSession(): Promise<boolean> {
},
);
if (!response.ok) {
clearAuthTokens();
clearAuthTokensIfCurrent(refreshToken);
return false;
}
const payload = (await response.json()) as RefreshResponse;
if (startGeneration !== logoutGeneration) return false;
if (getRefreshToken() !== refreshToken) return false;
storeAuthTokens(payload.access_token, payload.refresh_token);
setMustChangePassword(payload.must_change_password ?? false);
return true;
@ -133,10 +137,15 @@ export async function refreshSession(): Promise<boolean> {
return false;
}
})();
refreshInflight = promise;
refreshInflightToken = refreshToken;
try {
return await refreshInflight;
return await promise;
} finally {
refreshInflight = null;
if (refreshInflight === promise) {
refreshInflight = null;
refreshInflightToken = null;
}
}
}
@ -181,12 +190,13 @@ export async function authFetch(
}
if (response.status !== 401) return response;
const refreshToken = getRefreshToken();
const refreshed = await refreshSession();
if (!refreshed) {
if (isTauri) {
return (await retryWithTauriAutoAuth(resolvedInput, init)) ?? response;
}
clearAuthTokens();
clearAuthTokensIfCurrent(refreshToken);
void redirectToAuth();
return response;
}

View file

@ -1,34 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import type { ChatModelAdapter } from "@assistant-ui/react";
import type { MessageTiming, ToolCallMessagePart } from "@assistant-ui/core";
import { toast } from "@/lib/toast";
import { getAuthToken } from "@/features/auth/session";
import { apiUrl } from "@/lib/api-base";
import {
generateAudio,
listCachedGguf,
listCachedModels,
listGgufVariants,
loadModel,
streamChatCompletions,
validateModel,
} from "./chat-api";
import { pickFriendlyContainerName } from "../lib/friendly-names";
import {
createOpenAIContainer,
listOpenAIContainers,
} from "./openai-containers";
import {
encryptProviderApiKey,
isProviderKeyRotationError,
} from "./providers-api";
import { db } from "../db";
import type {
OpenAIChatCompletionsRequest,
OpenAIMessageContent,
} from "../types/api";
import { toast } from "@/lib/toast";
import type { MessageTiming, ToolCallMessagePart } from "@assistant-ui/core";
import type { ChatModelAdapter } from "@assistant-ui/react";
import {
getExternalProviderApiKey,
isCustomProviderType,
@ -38,6 +15,7 @@ import {
supportsProviderPromptCaching,
toExternalBackendProviderType,
} from "../external-providers";
import { pickFriendlyContainerName } from "../lib/friendly-names";
import {
EXTERNAL_MAX_OUTPUT_TOKENS,
clampReasoningEffortToLevels,
@ -51,12 +29,38 @@ import {
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
import { useExternalProvidersStore } from "../stores/external-providers-store";
import { isMultimodalResponse } from "../types/api";
import type {
OpenAIChatCompletionsRequest,
OpenAIMessageContent,
} from "../types/api";
import type { ChatModelSummary } from "../types/runtime";
import { getImageInputUnavailableReason } from "../utils/image-input-support";
import {
getStoredChatThread,
listStoredChatThreads,
updateStoredChatThread,
} from "../utils/chat-history-storage";
import {
hasClosedThinkTag,
parseAssistantContent,
} from "../utils/parse-assistant-content";
import {
generateAudio,
listCachedGguf,
listCachedModels,
listGgufVariants,
loadModel,
streamChatCompletions,
validateModel,
} from "./chat-api";
import {
createOpenAIContainer,
listOpenAIContainers,
} from "./openai-containers";
import {
encryptProviderApiKey,
isProviderKeyRotationError,
} from "./providers-api";
/** Server-side usage data from llama-server (via stream_options.include_usage). */
interface ServerUsage {
@ -119,11 +123,42 @@ export function isContextLimitError(message: string): boolean {
);
}
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function updateStoredChatThreadEventually(
threadId: string,
patch: Parameters<typeof updateStoredChatThread>[1],
): Promise<void> {
for (let attempt = 0; attempt < 10; attempt++) {
const updated = await updateStoredChatThread(threadId, patch).catch(
() => undefined,
);
if (updated) return;
await wait(50);
}
}
/** Parse "Title: ...\nURL: ...\nSnippet: ..." blocks into source content parts. */
function parseSourcesFromResult(raw: string): { type: "source"; sourceType: "url"; id: string; url: string; title: string; metadata?: { description: string } }[] {
function parseSourcesFromResult(raw: string): {
type: "source";
sourceType: "url";
id: string;
url: string;
title: string;
metadata?: { description: string };
}[] {
if (!raw) return [];
const blocks = raw.split(/\n---\n/).filter(Boolean);
const sources: { type: "source"; sourceType: "url"; id: string; url: string; title: string; metadata?: { description: string } }[] = [];
const sources: {
type: "source";
sourceType: "url";
id: string;
url: string;
title: string;
metadata?: { description: string };
}[] = [];
for (const block of blocks) {
const titleMatch = block.match(/Title:\s*(.+)/);
const urlMatch = block.match(/URL:\s*(.+)/);
@ -264,7 +299,7 @@ function collectImageParts(
message: RunMessage,
): Array<{ type: "image_url"; image_url: { url: string } }> {
const parts: Array<{ type: "image_url"; image_url: { url: string } }> = [];
for (const part of message.content ?? []) {
if (part.type === "image" && "image" in part) {
const src = (part as { image: string }).image;
@ -278,7 +313,7 @@ function collectImageParts(
}
}
}
if ("attachments" in message && (message.attachments?.length ?? 0) > 0) {
for (const attachment of message.attachments ?? []) {
for (const part of attachment.content ?? []) {
@ -298,7 +333,7 @@ function collectImageParts(
}
}
}
return parts;
}
@ -388,7 +423,12 @@ function findLatestUserAudioBase64(messages: RunMessages): string | undefined {
for (const part of message.content ?? []) {
if (part.type === "audio" && "audio" in part) {
const audioPart = (part as unknown as { type: "audio"; audio: string | { data: string; format: string } }).audio;
const audioPart = (
part as unknown as {
type: "audio";
audio: string | { data: string; format: string };
}
).audio;
const raw = typeof audioPart === "string" ? audioPart : audioPart?.data;
if (raw) return raw.startsWith("data:") ? raw.split(",")[1] : raw;
}
@ -407,7 +447,7 @@ async function resolveUseAdapter(
return undefined;
}
try {
const thread = await db.threads.get(threadId);
const thread = await getStoredChatThread(threadId);
if (!thread?.pairId) {
return undefined;
}
@ -426,8 +466,14 @@ async function resolveUseAdapter(
function waitForModelReady(abortSignal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const check = () => {
if (abortSignal?.aborted) { reject(new Error("Aborted")); return; }
if (!useChatRuntimeStore.getState().modelLoading) { resolve(); return; }
if (abortSignal?.aborted) {
reject(new Error("Aborted"));
return;
}
if (!useChatRuntimeStore.getState().modelLoading) {
resolve();
return;
}
setTimeout(check, 500);
};
check();
@ -515,12 +561,17 @@ async function autoLoadSmallestModel(): Promise<{
gguf_variant: variant.quant,
trust_remote_code: trustRemoteCode,
});
useChatRuntimeStore.getState().setCheckpoint(repo.repo_id, variant.quant);
useChatRuntimeStore
.getState()
.setCheckpoint(repo.repo_id, variant.quant);
const store = useChatRuntimeStore.getState();
store.setModelRequiresTrustRemoteCode(
loadResp.requires_trust_remote_code ?? false,
);
store.setParams({ ...store.params, maxTokens: loadResp.context_length ?? 131072 });
store.setParams({
...store.params,
maxTokens: loadResp.context_length ?? 131072,
});
// Add model to store so the selector shows the name
const autoModel: ChatModelSummary = {
id: repo.repo_id,
@ -538,12 +589,16 @@ async function autoLoadSmallestModel(): Promise<{
}
useChatRuntimeStore.setState({
ggufContextLength: loadResp.context_length ?? 131072,
ggufMaxContextLength: loadResp.max_context_length ?? loadResp.context_length ?? 131072,
ggufMaxContextLength:
loadResp.max_context_length ??
loadResp.context_length ??
131072,
supportsReasoning: loadResp.supports_reasoning ?? false,
reasoningAlwaysOn: loadResp.reasoning_always_on ?? false,
reasoningEnabled: loadResp.supports_reasoning ?? false,
reasoningStyle: loadResp.reasoning_style ?? "enable_thinking",
supportsPreserveThinking: loadResp.supports_preserve_thinking ?? false,
supportsPreserveThinking:
loadResp.supports_preserve_thinking ?? false,
supportsTools: loadResp.supports_tools ?? false,
toolsEnabled: loadResp.supports_tools ?? false,
codeToolsEnabled: loadResp.supports_tools ?? false,
@ -554,7 +609,9 @@ async function autoLoadSmallestModel(): Promise<{
loadedChatTemplateOverride: null,
loadedIsMultimodal: isMultimodalResponse(loadResp),
});
toast.success(`Loaded ${repo.repo_id} (${variant.quant})`, { id: toastId });
toast.success(`Loaded ${repo.repo_id} (${variant.quant})`, {
id: toastId,
});
return { loaded: true, blockedByTrustRemoteCode: false };
}
} catch {
@ -566,7 +623,9 @@ async function autoLoadSmallestModel(): Promise<{
// Fall back to safetensors models
if (modelRepos.length > 0) {
const sorted = [...modelRepos].sort((a, b) => a.size_bytes - b.size_bytes);
const sorted = [...modelRepos].sort(
(a, b) => a.size_bytes - b.size_bytes,
);
for (const repo of sorted) {
if (loadAttempts >= MAX_AUTO_LOAD_ATTEMPTS) break;
try {
@ -601,7 +660,8 @@ async function autoLoadSmallestModel(): Promise<{
reasoningAlwaysOn: sfLoadResp.reasoning_always_on ?? false,
reasoningEnabled: sfLoadResp.supports_reasoning ?? false,
reasoningStyle: sfLoadResp.reasoning_style ?? "enable_thinking",
supportsPreserveThinking: sfLoadResp.supports_preserve_thinking ?? false,
supportsPreserveThinking:
sfLoadResp.supports_preserve_thinking ?? false,
supportsTools: sfLoadResp.supports_tools ?? false,
// Parity with the GGUF branch above.
toolsEnabled: sfLoadResp.supports_tools ?? false,
@ -646,7 +706,8 @@ async function autoLoadSmallestModel(): Promise<{
// No cached models found — try downloading a small default GGUF
toast("Downloading a small model…", {
id: toastId,
description: "No downloaded models found. Fetching Gemma-4-E2B-it (UD-Q4_K_XL).",
description:
"No downloaded models found. Fetching Gemma-4-E2B-it (UD-Q4_K_XL).",
duration: 30000,
});
try {
@ -671,12 +732,17 @@ async function autoLoadSmallestModel(): Promise<{
gguf_variant: "UD-Q4_K_XL",
trust_remote_code: trustRemoteCode,
});
useChatRuntimeStore.getState().setCheckpoint("unsloth/gemma-4-E2B-it-GGUF", "UD-Q4_K_XL");
useChatRuntimeStore
.getState()
.setCheckpoint("unsloth/gemma-4-E2B-it-GGUF", "UD-Q4_K_XL");
const store = useChatRuntimeStore.getState();
store.setModelRequiresTrustRemoteCode(
loadResp.requires_trust_remote_code ?? false,
);
store.setParams({ ...store.params, maxTokens: loadResp.context_length ?? 131072 });
store.setParams({
...store.params,
maxTokens: loadResp.context_length ?? 131072,
});
const defaultModel: ChatModelSummary = {
id: "unsloth/gemma-4-E2B-it-GGUF",
name: loadResp.display_name ?? "gemma-4-E2B-it-GGUF",
@ -689,7 +755,8 @@ async function autoLoadSmallestModel(): Promise<{
}
useChatRuntimeStore.setState({
ggufContextLength: loadResp.context_length ?? 131072,
ggufMaxContextLength: loadResp.max_context_length ?? loadResp.context_length ?? 131072,
ggufMaxContextLength:
loadResp.max_context_length ?? loadResp.context_length ?? 131072,
supportsReasoning: loadResp.supports_reasoning ?? false,
reasoningAlwaysOn: loadResp.reasoning_always_on ?? false,
reasoningEnabled: loadResp.supports_reasoning ?? false,
@ -720,8 +787,7 @@ async function autoLoadSmallestModel(): Promise<{
hadNonTrustFailure = true;
return {
loaded: false,
blockedByTrustRemoteCode:
blockedByTrustRemoteCode && !hadNonTrustFailure,
blockedByTrustRemoteCode: blockedByTrustRemoteCode && !hadNonTrustFailure,
};
}
}
@ -729,6 +795,7 @@ async function autoLoadSmallestModel(): Promise<{
export function createOpenAIStreamAdapter(): ChatModelAdapter {
return {
async *run({ messages, abortSignal, unstable_threadId }) {
await useChatRuntimeStore.getState().hydratePersistedSettings();
let runtime = useChatRuntimeStore.getState();
// Capture the thread ID once at the start so it stays stable even if
// the user switches chats while waiting for model load / auto-load.
@ -763,11 +830,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
// Re-read store after potential auto-load / model ready wait
runtime = useChatRuntimeStore.getState();
const { params } = runtime;
const {
supportsTools,
toolsEnabled,
codeToolsEnabled,
} = runtime;
const { supportsTools, toolsEnabled, codeToolsEnabled } = runtime;
const externalSelection = parseExternalModelId(params.checkpoint);
const isExternalRequest = externalSelection !== null;
if (
@ -857,7 +920,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
if (audioBase64) {
const audioName = runtime.pendingAudioName;
if (audioName) {
const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
const lastUserMsg = [...messages]
.reverse()
.find((m) => m.role === "user");
if (lastUserMsg) sentAudioNames.set(lastUserMsg.id, audioName);
}
runtime.clearPendingAudio();
@ -905,8 +970,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
} catch (err) {
if (!abortSignal.aborted) {
toast.error("Audio generation failed", {
description:
err instanceof Error ? err.message : "Unknown error",
description: err instanceof Error ? err.message : "Unknown error",
});
}
throw err;
@ -967,7 +1031,10 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
// Tool call content parts — accumulated and yielded cumulatively.
// result is set directly on the tool-call part when tool_end arrives.
const toolCallParts: ToolCallMessagePart[] = [];
let serverMetadata: { usage?: ServerUsage; timings?: ServerTimings } | null = null;
let serverMetadata: {
usage?: ServerUsage;
timings?: ServerTimings;
} | null = null;
// Per-run cancellation token so a delayed stop POST cannot match
// the next run on the same thread.
@ -1042,16 +1109,17 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
NonNullable<OpenAIChatCompletionsRequest["reasoning_effort"]>,
"none" | "minimal" | "low" | "medium" | "high" | "max" | "xhigh"
>;
const fallbackExternalEffort =
(externalReasoningCaps.reasoningEffortLevels[0] ??
"low") as RequestReasoningEffort;
const fallbackExternalEffort = (externalReasoningCaps
.reasoningEffortLevels[0] ?? "low") as RequestReasoningEffort;
const selectedExternalEffort: RequestReasoningEffort =
clampReasoningEffortToLevels(
reasoningEffort,
externalReasoningCaps.reasoningEffortLevels,
) as RequestReasoningEffort;
const localReasoningEffort =
reasoningEffort === "low" || reasoningEffort === "medium" || reasoningEffort === "high"
reasoningEffort === "low" ||
reasoningEffort === "medium" ||
reasoningEffort === "high"
? reasoningEffort
: "low";
const externalReasoningEnabled =
@ -1078,7 +1146,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
);
if (codeExecEnabledForThisTurn && resolvedThreadId) {
try {
const thread = await db.threads.get(resolvedThreadId);
const thread = await getStoredChatThread(resolvedThreadId);
openaiCodeExecContainerId =
thread?.openaiCodeExecContainerId ?? null;
anthropicCodeExecContainerId =
@ -1114,11 +1182,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
openaiCodeExecContainerId &&
!activeContainerIds.has(openaiCodeExecContainerId)
) {
void db.threads
.update(resolvedThreadId, {
openaiCodeExecContainerId: null,
})
.catch(() => {});
void updateStoredChatThreadEventually(resolvedThreadId, {
openaiCodeExecContainerId: null,
}).catch(() => {});
openaiCodeExecContainerId = null;
}
}
@ -1136,32 +1202,28 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
externalProvider.providerType === "openai"
) {
try {
const others = await db.threads
.orderBy("createdAt")
.reverse()
.toArray();
const others = await listStoredChatThreads({
includeArchived: true,
});
for (const t of others) {
if (t.id === resolvedThreadId) continue;
if (!t.openaiCodeExecContainerId) continue;
// Skip inherited ids that are not in the active
// container set — they would 400 on send. Also
// null them on the source thread so the next
// inheritance pass doesn't re-pick the same dead id.
// Skip ids not in active set; null on source thread so
// the next pass doesn't re-pick a dead id.
if (
activeContainerIds &&
!activeContainerIds.has(t.openaiCodeExecContainerId)
) {
void db.threads
.update(t.id, { openaiCodeExecContainerId: null })
void updateStoredChatThreadEventually(t.id, {
openaiCodeExecContainerId: null,
})
.catch(() => {});
continue;
}
openaiCodeExecContainerId = t.openaiCodeExecContainerId;
void db.threads
.update(resolvedThreadId, {
openaiCodeExecContainerId,
})
.catch(() => {});
void updateStoredChatThreadEventually(resolvedThreadId, {
openaiCodeExecContainerId,
}).catch(() => {});
break;
}
} catch {
@ -1180,8 +1242,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
externalProvider.providerType === "openai"
) {
const ttl = externalProvider.openaiContainerTtlMinutes;
const ttlToUse =
typeof ttl === "number" && ttl >= 1 ? ttl : 20;
const ttlToUse = typeof ttl === "number" && ttl >= 1 ? ttl : 20;
try {
const created = await createOpenAIContainer(
{
@ -1198,10 +1259,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
},
);
openaiCodeExecContainerId = created.id;
void db.threads
.update(resolvedThreadId, {
openaiCodeExecContainerId: created.id,
})
void updateStoredChatThreadEventually(resolvedThreadId, {
openaiCodeExecContainerId: created.id,
})
.catch(() => {});
} catch {
// Fall back to backend's container_auto path on
@ -1254,7 +1314,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
// schema — for Anthropic that's the entries appended to
// body["tools"] inside _stream_anthropic.
...((toolsEnabled &&
providerSupportsBuiltinWebSearch(externalProvider.providerType)) ||
providerSupportsBuiltinWebSearch(
externalProvider.providerType,
)) ||
(codeToolsEnabled &&
providerSupportsBuiltinCodeExecution(
externalProvider.providerType,
@ -1360,7 +1422,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
: {}
: { enable_thinking: reasoningEnabled }
: {}),
...(supportsPreserveThinking ? { preserve_thinking: preserveThinking } : {}),
...(supportsPreserveThinking
? { preserve_thinking: preserveThinking }
: {}),
...(supportsTools && (toolsEnabled || codeToolsEnabled)
? {
enable_tools: true,
@ -1368,8 +1432,10 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
...(toolsEnabled ? ["web_search"] : []),
...(codeToolsEnabled ? ["python", "terminal"] : []),
],
auto_heal_tool_calls: useChatRuntimeStore.getState().autoHealToolCalls,
max_tool_calls_per_message: useChatRuntimeStore.getState().maxToolCallsPerMessage,
auto_heal_tool_calls:
useChatRuntimeStore.getState().autoHealToolCalls,
max_tool_calls_per_message:
useChatRuntimeStore.getState().maxToolCallsPerMessage,
tool_call_timeout: (() => {
const mins = useChatRuntimeStore.getState().toolCallTimeout;
return mins >= 9999 ? 9999 : mins * 60;
@ -1389,16 +1455,20 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
for await (const chunk of stream) {
// Handle tool status events
const toolStatusText = (chunk as unknown as { _toolStatus?: string })._toolStatus;
const toolStatusText = (
chunk as unknown as { _toolStatus?: string }
)._toolStatus;
if (toolStatusText !== undefined) {
runtime.setToolStatus(toolStatusText || null);
continue;
}
// Emit tool-call content parts for assistant-ui.
// On tool_start: add a new tool-call part (renders in "running" state).
// On tool_end: set result on the existing part (transitions to "complete").
const toolEvent = (chunk as unknown as { _toolEvent?: Record<string, unknown> })._toolEvent;
const toolEvent = (
chunk as unknown as { _toolEvent?: Record<string, unknown> }
)._toolEvent;
if (toolEvent !== undefined) {
// OpenAI shell-tool container persistence — see
// ThreadRecord.openaiCodeExecContainerId. The backend
@ -1414,29 +1484,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
externalProvider?.providerType === "anthropic"
? "anthropicCodeExecContainerId"
: "openaiCodeExecContainerId";
// On the first turn of a brand-new thread the row
// may not be in Dexie yet when this SSE event
// fires — db.threads.update silently affects 0
// rows, the next turn re-reads null, and Anthropic
// auto-creates a fresh container. Retry briefly so
// assistant-ui's own DexieAdapter.initialize lands
// the row first (with the correct modelType for
// base / lora / compare contexts) and our update
// sticks on a subsequent attempt.
try {
for (let attempt = 0; attempt < 10; attempt++) {
const affected = await db.threads.update(
resolvedThreadId,
{ [field]: newContainerId },
);
if (affected > 0) break;
await new Promise((resolve) =>
setTimeout(resolve, 50),
);
}
} catch {
/* best-effort: container reuse is an optimization */
}
void updateStoredChatThreadEventually(resolvedThreadId, {
[field]: newContainerId,
}).catch(() => {});
}
continue;
}
@ -1446,17 +1496,19 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
externalProvider?.providerType === "anthropic"
? "anthropicCodeExecContainerId"
: "openaiCodeExecContainerId";
void db.threads
.update(resolvedThreadId, {
[field]: null,
})
void updateStoredChatThreadEventually(resolvedThreadId, {
[field]: null,
})
.catch(() => {});
}
continue;
}
if (toolEvent.type === "tool_start") {
const id = (toolEvent.tool_call_id as string) || `${toolEvent.tool_name}_${Date.now()}`;
const toolArgs = (toolEvent.arguments ?? {}) as ToolCallMessagePart["args"];
const id =
(toolEvent.tool_call_id as string) ||
`${toolEvent.tool_name}_${Date.now()}`;
const toolArgs = (toolEvent.arguments ??
{}) as ToolCallMessagePart["args"];
toolCallParts.push({
type: "tool-call" as const,
toolCallId: id,
@ -1465,21 +1517,29 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
args: toolArgs,
});
} else if (toolEvent.type === "tool_end") {
const id = (toolEvent.tool_call_id as string) ||
toolCallParts[toolCallParts.length - 1]?.toolCallId || "";
const idx = toolCallParts.findIndex((p) => p.toolCallId === id);
const id =
(toolEvent.tool_call_id as string) ||
toolCallParts[toolCallParts.length - 1]?.toolCallId ||
"";
const idx = toolCallParts.findIndex(
(p) => p.toolCallId === id,
);
if (idx !== -1) {
const rawResult = (toolEvent.result as string) ?? "";
const imgMarker = "\n__IMAGES__:";
const imgIdx = rawResult.lastIndexOf(imgMarker);
let parsedResult: string | { text: string; images: string[]; sessionId: string };
let parsedResult:
| string
| { text: string; images: string[]; sessionId: string };
if (imgIdx !== -1) {
const text = rawResult.slice(0, imgIdx);
// Fall back to "_default" to match the backend sandbox directory
// used when no session_id is provided (see tools.py _get_workdir).
const sessionId = resolvedThreadId || "_default";
try {
const images = JSON.parse(rawResult.slice(imgIdx + imgMarker.length)) as string[];
const images = JSON.parse(
rawResult.slice(imgIdx + imgMarker.length),
) as string[];
parsedResult = { text, images, sessionId };
} catch {
parsedResult = rawResult;
@ -1487,7 +1547,10 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
} else {
parsedResult = rawResult;
}
toolCallParts[idx] = { ...toolCallParts[idx], result: parsedResult };
toolCallParts[idx] = {
...toolCallParts[idx],
result: parsedResult,
};
}
}
// Yield cumulative state so tool UI updates (tools first, text after)
@ -1495,7 +1558,11 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
yield {
content: [...toolCallParts, ...textParts],
metadata: {
timing: buildTiming(streamStartTime, totalChunks, firstTokenTime),
timing: buildTiming(
streamStartTime,
totalChunks,
firstTokenTime,
),
custom: { reasoningDuration },
},
};
@ -1506,7 +1573,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
if (chunk.choices?.length === 0 && chunk.usage) {
serverMetadata = {
usage: chunk.usage,
timings: (chunk as Record<string, unknown>).timings as ServerTimings | undefined,
timings: (chunk as Record<string, unknown>).timings as
| ServerTimings
| undefined,
};
continue;
}
@ -1617,11 +1686,20 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
}
const parts = parseAssistantContent(cumulativeText);
if (parts.some((part) => part.type === "reasoning") && !reasoningStartAt) {
if (
parts.some((part) => part.type === "reasoning") &&
!reasoningStartAt
) {
reasoningStartAt = Date.now();
}
if (hasClosedThinkTag(cumulativeText) && reasoningStartAt && !reasoningDuration) {
reasoningDuration = Math.round((Date.now() - reasoningStartAt) / 1000);
if (
hasClosedThinkTag(cumulativeText) &&
reasoningStartAt &&
!reasoningDuration
) {
reasoningDuration = Math.round(
(Date.now() - reasoningStartAt) / 1000,
);
}
if (parts.length > 0 || toolCallParts.length > 0) {
@ -1671,12 +1749,14 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
) {
return [];
}
return parseSourcesFromResult(typeof tc.result === "string" ? tc.result : "");
return parseSourcesFromResult(
typeof tc.result === "string" ? tc.result : "",
);
});
const meta = serverMetadata;
const finalTokenCount = meta?.usage?.completion_tokens
?? estimateTokenCount(cumulativeText);
const finalTokenCount =
meta?.usage?.completion_tokens ?? estimateTokenCount(cumulativeText);
const finalTokPerSec = meta?.timings?.predicted_per_second;
const serverPromptEvalTime = meta?.timings?.prompt_ms;
@ -1716,19 +1796,23 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
custom: {
reasoningDuration,
serverTimings: meta?.timings ?? undefined,
contextUsage: meta?.usage ? {
promptTokens: meta.usage.prompt_tokens,
completionTokens: meta.usage.completion_tokens,
totalTokens: meta.usage.total_tokens,
cachedTokens: meta.timings?.cache_n ?? 0,
modelId: params.checkpoint,
} : undefined,
contextUsage: meta?.usage
? {
promptTokens: meta.usage.prompt_tokens,
completionTokens: meta.usage.completion_tokens,
totalTokens: meta.usage.total_tokens,
cachedTokens: meta.timings?.cache_n ?? 0,
modelId: params.checkpoint,
}
: undefined,
timing: finalTiming,
},
},
};
} catch (err) {
settleFirstTokenErr(err instanceof Error ? err : new Error("Generation failed"));
settleFirstTokenErr(
err instanceof Error ? err : new Error("Generation failed"),
);
if (!abortSignal.aborted) {
const msg = err instanceof Error ? err.message : String(err);
if (isContextLimitError(msg)) {
@ -1739,7 +1823,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
toast.error("Context limit reached", {
description:
"The conversation has filled the model's context window. " +
"Increase \"Context Length\" in the chat Settings panel (⚙ in the top-right), " +
'Increase "Context Length" in the chat Settings panel (⚙ in the top-right), ' +
"or start a new chat.",
duration: 8000,
});
@ -1756,14 +1840,12 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
runtime.setToolStatus(null);
clearTimeout(warmupTimer);
if (waitingFirstChunk) {
if (!firstTokenSettled) {
if (abortSignal.aborted) {
settleFirstTokenErr(new Error("Cancelled"));
} else {
settleFirstTokenErr(new Error("No tokens received"));
}
} else {
if (firstTokenSettled) {
settleFirstTokenOk();
} else if (abortSignal.aborted) {
settleFirstTokenErr(new Error("Cancelled"));
} else {
settleFirstTokenErr(new Error("No tokens received"));
}
}
runtime.setThreadRunning(threadKey, false);

View file

@ -3,6 +3,7 @@
import { authFetch } from "@/features/auth";
import { formatFastApiDetail } from "@/lib/format-fastapi-error";
import type { MessageRecord, ModelType, ThreadRecord } from "../types";
import type {
AudioGenerationResponse,
GgufVariantsResponse,
@ -17,6 +18,14 @@ import type {
ValidateModelResponse,
} from "../types/api";
export const CHAT_HISTORY_UPDATED_EVENT = "unsloth-chat-history-updated";
export function notifyChatHistoryUpdated(): void {
if (typeof window !== "undefined") {
window.dispatchEvent(new Event(CHAT_HISTORY_UPDATED_EVENT));
}
}
function parseErrorText(status: number, body: unknown): string {
if (body && typeof body === "object") {
const detail = (body as { detail?: unknown }).detail;
@ -41,7 +50,9 @@ export async function listModels(): Promise<ListModelsResponse> {
return parseJsonOrThrow<ListModelsResponse>(response);
}
export async function listLoras(outputsDir?: string): Promise<ListLorasResponse> {
export async function listLoras(
outputsDir?: string,
): Promise<ListLorasResponse> {
const query = outputsDir
? `?${new URLSearchParams({ outputs_dir: outputsDir }).toString()}`
: "";
@ -104,13 +115,19 @@ export async function getGgufDownloadProgress(
repoId: string,
variant: string,
expectedBytes: number,
): Promise<{ downloaded_bytes: number; expected_bytes: number; progress: number }> {
): Promise<{
downloaded_bytes: number;
expected_bytes: number;
progress: number;
}> {
const params = new URLSearchParams({
repo_id: repoId,
variant,
expected_bytes: String(expectedBytes),
});
const response = await authFetch(`/api/models/gguf-download-progress?${params}`);
const response = await authFetch(
`/api/models/gguf-download-progress?${params}`,
);
return parseJsonOrThrow(response);
}
@ -205,7 +222,10 @@ export async function listCachedModels(): Promise<CachedModelRepo[]> {
return data.cached;
}
export async function deleteCachedModel(repoId: string, variant?: string): Promise<void> {
export async function deleteCachedModel(
repoId: string,
variant?: string,
): Promise<void> {
const payload: Record<string, string> = { repo_id: repoId };
if (variant) payload.variant = variant;
const response = await authFetch("/api/models/delete-cached", {
@ -263,6 +283,246 @@ export async function removeScanFolder(id: number): Promise<void> {
await parseJsonOrThrow<unknown>(response);
}
export async function listChatThreads(
args: {
modelType?: ModelType;
pairId?: string;
includeArchived?: boolean;
} = {},
): Promise<ThreadRecord[]> {
const params = new URLSearchParams();
if (args.modelType) params.set("model_type", args.modelType);
if (args.pairId) params.set("pair_id", args.pairId);
if (args.includeArchived !== undefined) {
params.set("include_archived", String(args.includeArchived));
}
const qs = params.toString();
const response = await authFetch(`/api/chat/threads${qs ? `?${qs}` : ""}`);
const data = await parseJsonOrThrow<{ threads: ThreadRecord[] }>(response);
return data.threads;
}
export async function getChatThread(
threadId: string,
): Promise<ThreadRecord | null> {
const response = await authFetch(
`/api/chat/threads/${encodeURIComponent(threadId)}`,
);
if (response.status === 404) return null;
return parseJsonOrThrow<ThreadRecord>(response);
}
export async function saveChatThread(
thread: ThreadRecord,
): Promise<ThreadRecord> {
const response = await authFetch("/api/chat/threads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(thread),
});
const savedThread = await parseJsonOrThrow<ThreadRecord>(response);
notifyChatHistoryUpdated();
return savedThread;
}
export async function updateChatThread(
threadId: string,
patch: Partial<ThreadRecord>,
): Promise<ThreadRecord> {
const response = await authFetch(
`/api/chat/threads/${encodeURIComponent(threadId)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
},
);
const thread = await parseJsonOrThrow<ThreadRecord>(response);
notifyChatHistoryUpdated();
return thread;
}
export async function deleteChatThreads(threadIds: string[]): Promise<void> {
if (threadIds.length === 0) return;
const response = await authFetch("/api/chat/threads", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: threadIds }),
});
await parseJsonOrThrow<unknown>(response);
notifyChatHistoryUpdated();
}
export async function listChatMessages(
threadId: string,
): Promise<MessageRecord[]> {
const response = await authFetch(
`/api/chat/threads/${encodeURIComponent(threadId)}/messages`,
);
if (response.status === 404) return [];
const data = await parseJsonOrThrow<{ messages: MessageRecord[] }>(response);
return data.messages;
}
/**
* Fetch messages for many threads in one HTTP call. Falls back to
* per-thread listChatMessages on 404/405 (older servers without the
* batch route).
*/
export async function batchListChatMessages(
threadIds: string[],
): Promise<Map<string, MessageRecord[]>> {
const out = new Map<string, MessageRecord[]>();
if (threadIds.length === 0) return out;
const response = await authFetch("/api/chat/messages:batch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ threadIds }),
});
if (response.status === 404 || response.status === 405) {
// Older server: fall back to per-thread fetches.
const per = await Promise.all(
threadIds.map(async (id) => [id, await listChatMessages(id)] as const),
);
for (const [id, msgs] of per) out.set(id, msgs);
return out;
}
const data = await parseJsonOrThrow<{
messagesByThreadId: Record<string, MessageRecord[]>;
}>(response);
for (const id of threadIds) {
out.set(id, data.messagesByThreadId[id] ?? []);
}
return out;
}
export async function getChatMessage(
threadId: string,
messageId: string,
): Promise<MessageRecord | null> {
const response = await authFetch(
`/api/chat/threads/${encodeURIComponent(threadId)}/messages/${encodeURIComponent(messageId)}`,
);
if (response.status === 404) return null;
return parseJsonOrThrow<MessageRecord>(response);
}
export async function saveChatMessage(
message: MessageRecord,
): Promise<MessageRecord> {
const response = await authFetch(
`/api/chat/threads/${encodeURIComponent(message.threadId)}/messages/${encodeURIComponent(message.id)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(message),
},
);
const savedMessage = await parseJsonOrThrow<MessageRecord>(response);
notifyChatHistoryUpdated();
return savedMessage;
}
export async function syncChatMessages(
threadId: string,
messages: MessageRecord[],
options: { pruneMissing?: boolean } = {},
): Promise<MessageRecord[]> {
const response = await authFetch(
`/api/chat/threads/${encodeURIComponent(threadId)}/messages`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages,
pruneMissing: options.pruneMissing ?? false,
}),
},
);
const data = await parseJsonOrThrow<{ messages: MessageRecord[] }>(response);
notifyChatHistoryUpdated();
return data.messages;
}
export async function countBackendChats(): Promise<number> {
const response = await authFetch("/api/chat/count");
const data = await parseJsonOrThrow<{ count: number }>(response);
return data.count;
}
export async function clearBackendChats(
options: { notify?: boolean } = {},
): Promise<void> {
const response = await authFetch("/api/chat", { method: "DELETE" });
await parseJsonOrThrow<unknown>(response);
if (options.notify !== false) {
notifyChatHistoryUpdated();
}
}
export async function buildBackendChatExport(): Promise<{
exportedAt: string;
version: number;
threadCount: number;
threads: ThreadRecord[];
messages: MessageRecord[];
}> {
const response = await authFetch("/api/chat/export");
return parseJsonOrThrow(response);
}
// Legacy-Dexie import ledger. The server-side source of truth that
// replaces the boolean localStorage sentinel
// (`unsloth_chat_legacy_imported_to_studio_db`) so a studio.db wipe
// makes the import recoverable.
export async function listChatImportLedger(): Promise<Set<string>> {
const response = await authFetch("/api/chat/import-ledger");
// Backend deployments that don't have this endpoint yet behave the
// same as an empty ledger -- caller treats every legacy thread as
// un-imported and tries to import. The UPSERT semantics in
// syncChatMessages prevent duplicates, so this fallback is safe.
if (response.status === 404 || response.status === 405) return new Set();
const data = await parseJsonOrThrow<{ threadIds: string[] }>(response);
return new Set(data.threadIds);
}
export interface RecordChatImportLedgerResult {
accepted: number;
inserted: number;
// false when the backend predates /api/chat/import-ledger (404/405/501)
// so the caller can avoid poisoning the localStorage perf hint -- the
// next launch will retry the (idempotent) import.
supported: boolean;
}
export async function recordChatImportLedger(
threadIds: string[],
): Promise<RecordChatImportLedgerResult> {
if (threadIds.length === 0) {
return { accepted: 0, inserted: 0, supported: true };
}
const response = await authFetch("/api/chat/import-ledger", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ threadIds }),
});
if (
response.status === 404 ||
response.status === 405 ||
response.status === 501
) {
return { accepted: 0, inserted: 0, supported: false };
}
const data = await parseJsonOrThrow<{ accepted: number; inserted: number }>(
response,
);
return {
accepted: data.accepted,
inserted: data.inserted,
supported: true,
};
}
export interface BrowseEntry {
name: string;
has_models: boolean;
@ -382,12 +642,17 @@ export async function* streamChatCompletions(
}
// Tool status events are custom SSE payloads, not OpenAI chunks
if ("type" in parsed && parsed.type === "tool_status") {
yield { _toolStatus: parsed.content ?? "" } as unknown as OpenAIChatChunk;
yield {
_toolStatus: parsed.content ?? "",
} as unknown as OpenAIChatChunk;
separatorIndex = buffer.search(/\r?\n\r?\n/);
continue;
}
// Tool start/end events carry full input/output for the tool outputs panel
if ("type" in parsed && (parsed.type === "tool_start" || parsed.type === "tool_end")) {
if (
"type" in parsed &&
(parsed.type === "tool_start" || parsed.type === "tool_end")
) {
yield { _toolEvent: parsed } as unknown as OpenAIChatChunk;
separatorIndex = buffer.search(/\r?\n\r?\n/);
continue;

View file

@ -0,0 +1,90 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { authFetch } from "@/features/auth";
import type { ChatPresetSource } from "../presets/preset-policy";
import type { ReasoningEffort } from "../stores/chat-runtime-store";
import type { InferenceParams } from "../types/runtime";
export type PersistedInferenceParams = Partial<
Omit<InferenceParams, "checkpoint">
>;
export interface PersistedChatPreset {
name: string;
params: PersistedInferenceParams;
}
export interface PersistedChatSettings {
inferenceParams?: PersistedInferenceParams;
customPresets?: PersistedChatPreset[];
activePreset?: string;
activePresetSource?: ChatPresetSource;
autoTitle?: boolean;
reasoningEffort?: ReasoningEffort;
preserveThinking?: boolean;
autoHealToolCalls?: boolean;
maxToolCallsPerMessage?: number;
toolCallTimeout?: number;
}
interface ChatSettingsResponse {
settings: PersistedChatSettings;
}
function parseErrorText(status: number, body: unknown): string {
if (
body &&
typeof body === "object" &&
"detail" in body &&
typeof body.detail === "string"
) {
return body.detail;
}
if (
body &&
typeof body === "object" &&
"detail" in body &&
body.detail != null
) {
return `Request failed (${status}): ${JSON.stringify(body.detail)}`;
}
if (
body &&
typeof body === "object" &&
"message" in body &&
typeof body.message === "string"
) {
return body.message;
}
return `Request failed (${status})`;
}
async function parseJsonOrThrow<T>(response: Response): Promise<T> {
const body = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(parseErrorText(response.status, body));
}
return body as T;
}
export async function getChatSettings(): Promise<PersistedChatSettings> {
const response = await authFetch("/api/chat/settings");
const data = await parseJsonOrThrow<ChatSettingsResponse>(response);
return data.settings;
}
export async function saveChatSettingsPatch(
patch: PersistedChatSettings,
options: { keepalive?: boolean } = {},
): Promise<PersistedChatSettings> {
const response = await authFetch("/api/chat/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
// keepalive lets the PUT survive a tab close from the beforeunload flush.
keepalive: options.keepalive,
});
const data = await parseJsonOrThrow<ChatSettingsResponse>(response);
return data.settings;
}

View file

@ -1,226 +1,226 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import forge from "node-forge";
import { authFetch } from "@/features/auth";
import { formatFastApiDetail } from "@/lib/format-fastapi-error";
export interface ProviderRegistryEntry {
provider_type: string;
display_name: string;
base_url: string;
default_models: string[];
supports_streaming: boolean;
supports_vision: boolean;
supports_tool_calling: boolean;
/** remote = fetch /models; curated = huge catalogs — UI uses defaults + manual IDs only */
model_list_mode?: "remote" | "curated";
}
export interface ProviderConfig {
id: string;
provider_type: string;
display_name: string;
base_url: string;
is_enabled: boolean;
created_at: string;
updated_at: string;
}
export interface ProviderModelInfo {
id: string;
display_name: string;
context_length?: number | null;
owned_by?: string | null;
}
export interface ProviderTestResult {
success: boolean;
message: string;
models_count?: number | null;
}
function parseErrorText(status: number, body: unknown): string {
if (body && typeof body === "object") {
const detail = (body as { detail?: unknown }).detail;
const formatted = formatFastApiDetail(detail);
if (formatted) return formatted;
const message = (body as { message?: unknown }).message;
if (typeof message === "string" && message) return message;
}
return `Request failed (${status})`;
}
async function parseJsonOrThrow<T>(response: Response): Promise<T> {
const body = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(parseErrorText(response.status, body));
}
return body as T;
}
export function isProviderKeyRotationError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const normalized = error.message.toLowerCase();
return (
normalized.includes("public key may have changed") ||
normalized.includes("server key may have changed")
);
}
let cachedPublicKeyPem: string | null = null;
let cachedForgeKey: forge.pki.rsa.PublicKey | null = null;
export function clearProviderPublicKeyCache(): void {
cachedPublicKeyPem = null;
cachedForgeKey = null;
}
async function importProviderPublicKey(
forceRefresh = false,
): Promise<forge.pki.rsa.PublicKey> {
if (!forceRefresh && cachedForgeKey) {
return cachedForgeKey;
}
const response = await authFetch("/api/providers/public-key");
const body = await parseJsonOrThrow<{ public_key: string }>(response);
const publicKeyPem = body.public_key?.trim();
if (!publicKeyPem) {
throw new Error("Provider public key is missing.");
}
if (!forceRefresh && cachedPublicKeyPem === publicKeyPem && cachedForgeKey) {
return cachedForgeKey;
}
const forgeKey = forge.pki.publicKeyFromPem(publicKeyPem);
cachedPublicKeyPem = publicKeyPem;
cachedForgeKey = forgeKey;
return forgeKey;
}
export async function encryptProviderApiKey(
plaintextApiKey: string,
forceRefresh = false,
): Promise<string> {
const key = await importProviderPublicKey(forceRefresh);
const encrypted = key.encrypt(plaintextApiKey, "RSA-OAEP", {
md: forge.md.sha256.create(),
mgf1: { md: forge.md.sha256.create() },
});
return forge.util.encode64(encrypted);
}
export async function listProviderRegistry(): Promise<ProviderRegistryEntry[]> {
const response = await authFetch("/api/providers/registry");
return parseJsonOrThrow<ProviderRegistryEntry[]>(response);
}
export async function listProviderConfigs(): Promise<ProviderConfig[]> {
const response = await authFetch("/api/providers/");
return parseJsonOrThrow<ProviderConfig[]>(response);
}
export async function createProviderConfig(payload: {
providerType: string;
displayName: string;
baseUrl?: string | null;
}): Promise<ProviderConfig> {
const response = await authFetch("/api/providers/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider_type: payload.providerType,
display_name: payload.displayName,
base_url: payload.baseUrl ?? null,
}),
});
return parseJsonOrThrow<ProviderConfig>(response);
}
export async function deleteProviderConfig(providerId: string): Promise<void> {
const response = await authFetch(`/api/providers/${providerId}`, {
method: "DELETE",
});
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(parseErrorText(response.status, body));
}
}
export async function updateProviderConfig(
providerId: string,
payload: {
displayName?: string;
baseUrl?: string | null;
isEnabled?: boolean;
},
): Promise<ProviderConfig> {
const response = await authFetch(`/api/providers/${providerId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...(payload.displayName === undefined ? {} : { display_name: payload.displayName }),
...(payload.baseUrl === undefined ? {} : { base_url: payload.baseUrl }),
...(payload.isEnabled === undefined ? {} : { is_enabled: payload.isEnabled }),
}),
});
return parseJsonOrThrow<ProviderConfig>(response);
}
async function withApiKeyEncryptionRetry<T>(
plaintextApiKey: string,
call: (encryptedApiKey: string | null) => Promise<T>,
): Promise<T> {
// Empty key (local providers): skip RSA round-trip and let the backend omit auth.
if (!plaintextApiKey) {
return await call(null);
}
try {
const encrypted = await encryptProviderApiKey(plaintextApiKey, false);
return await call(encrypted);
} catch (error) {
if (!isProviderKeyRotationError(error)) {
throw error;
}
clearProviderPublicKeyCache();
const encrypted = await encryptProviderApiKey(plaintextApiKey, true);
return await call(encrypted);
}
}
export async function testProviderConnection(payload: {
providerType: string;
apiKey: string;
baseUrl?: string | null;
}): Promise<ProviderTestResult> {
return withApiKeyEncryptionRetry(payload.apiKey, async (encryptedApiKey) => {
const response = await authFetch("/api/providers/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider_type: payload.providerType,
encrypted_api_key: encryptedApiKey,
base_url: payload.baseUrl ?? null,
}),
});
return parseJsonOrThrow<ProviderTestResult>(response);
});
}
export async function listProviderModels(payload: {
providerType: string;
apiKey: string;
baseUrl?: string | null;
}): Promise<ProviderModelInfo[]> {
return withApiKeyEncryptionRetry(payload.apiKey, async (encryptedApiKey) => {
const response = await authFetch("/api/providers/models", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider_type: payload.providerType,
encrypted_api_key: encryptedApiKey,
base_url: payload.baseUrl ?? null,
}),
});
return parseJsonOrThrow<ProviderModelInfo[]>(response);
});
}
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import forge from "node-forge";
import { authFetch } from "@/features/auth";
import { formatFastApiDetail } from "@/lib/format-fastapi-error";
export interface ProviderRegistryEntry {
provider_type: string;
display_name: string;
base_url: string;
default_models: string[];
supports_streaming: boolean;
supports_vision: boolean;
supports_tool_calling: boolean;
/** remote = fetch /models; curated = huge catalogs — UI uses defaults + manual IDs only */
model_list_mode?: "remote" | "curated";
}
export interface ProviderConfig {
id: string;
provider_type: string;
display_name: string;
base_url: string;
is_enabled: boolean;
created_at: string;
updated_at: string;
}
export interface ProviderModelInfo {
id: string;
display_name: string;
context_length?: number | null;
owned_by?: string | null;
}
export interface ProviderTestResult {
success: boolean;
message: string;
models_count?: number | null;
}
function parseErrorText(status: number, body: unknown): string {
if (body && typeof body === "object") {
const detail = (body as { detail?: unknown }).detail;
const formatted = formatFastApiDetail(detail);
if (formatted) return formatted;
const message = (body as { message?: unknown }).message;
if (typeof message === "string" && message) return message;
}
return `Request failed (${status})`;
}
async function parseJsonOrThrow<T>(response: Response): Promise<T> {
const body = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(parseErrorText(response.status, body));
}
return body as T;
}
export function isProviderKeyRotationError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const normalized = error.message.toLowerCase();
return (
normalized.includes("public key may have changed") ||
normalized.includes("server key may have changed")
);
}
let cachedPublicKeyPem: string | null = null;
let cachedForgeKey: forge.pki.rsa.PublicKey | null = null;
export function clearProviderPublicKeyCache(): void {
cachedPublicKeyPem = null;
cachedForgeKey = null;
}
async function importProviderPublicKey(
forceRefresh = false,
): Promise<forge.pki.rsa.PublicKey> {
if (!forceRefresh && cachedForgeKey) {
return cachedForgeKey;
}
const response = await authFetch("/api/providers/public-key");
const body = await parseJsonOrThrow<{ public_key: string }>(response);
const publicKeyPem = body.public_key?.trim();
if (!publicKeyPem) {
throw new Error("Provider public key is missing.");
}
if (!forceRefresh && cachedPublicKeyPem === publicKeyPem && cachedForgeKey) {
return cachedForgeKey;
}
const forgeKey = forge.pki.publicKeyFromPem(publicKeyPem);
cachedPublicKeyPem = publicKeyPem;
cachedForgeKey = forgeKey;
return forgeKey;
}
export async function encryptProviderApiKey(
plaintextApiKey: string,
forceRefresh = false,
): Promise<string> {
const key = await importProviderPublicKey(forceRefresh);
const encrypted = key.encrypt(plaintextApiKey, "RSA-OAEP", {
md: forge.md.sha256.create(),
mgf1: { md: forge.md.sha256.create() },
});
return forge.util.encode64(encrypted);
}
export async function listProviderRegistry(): Promise<ProviderRegistryEntry[]> {
const response = await authFetch("/api/providers/registry");
return parseJsonOrThrow<ProviderRegistryEntry[]>(response);
}
export async function listProviderConfigs(): Promise<ProviderConfig[]> {
const response = await authFetch("/api/providers/");
return parseJsonOrThrow<ProviderConfig[]>(response);
}
export async function createProviderConfig(payload: {
providerType: string;
displayName: string;
baseUrl?: string | null;
}): Promise<ProviderConfig> {
const response = await authFetch("/api/providers/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider_type: payload.providerType,
display_name: payload.displayName,
base_url: payload.baseUrl ?? null,
}),
});
return parseJsonOrThrow<ProviderConfig>(response);
}
export async function deleteProviderConfig(providerId: string): Promise<void> {
const response = await authFetch(`/api/providers/${providerId}`, {
method: "DELETE",
});
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(parseErrorText(response.status, body));
}
}
export async function updateProviderConfig(
providerId: string,
payload: {
displayName?: string;
baseUrl?: string | null;
isEnabled?: boolean;
},
): Promise<ProviderConfig> {
const response = await authFetch(`/api/providers/${providerId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...(payload.displayName === undefined ? {} : { display_name: payload.displayName }),
...(payload.baseUrl === undefined ? {} : { base_url: payload.baseUrl }),
...(payload.isEnabled === undefined ? {} : { is_enabled: payload.isEnabled }),
}),
});
return parseJsonOrThrow<ProviderConfig>(response);
}
async function withApiKeyEncryptionRetry<T>(
plaintextApiKey: string,
call: (encryptedApiKey: string | null) => Promise<T>,
): Promise<T> {
// Empty key (local providers): skip RSA round-trip and let the backend omit auth.
if (!plaintextApiKey) {
return await call(null);
}
try {
const encrypted = await encryptProviderApiKey(plaintextApiKey, false);
return await call(encrypted);
} catch (error) {
if (!isProviderKeyRotationError(error)) {
throw error;
}
clearProviderPublicKeyCache();
const encrypted = await encryptProviderApiKey(plaintextApiKey, true);
return await call(encrypted);
}
}
export async function testProviderConnection(payload: {
providerType: string;
apiKey: string;
baseUrl?: string | null;
}): Promise<ProviderTestResult> {
return withApiKeyEncryptionRetry(payload.apiKey, async (encryptedApiKey) => {
const response = await authFetch("/api/providers/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider_type: payload.providerType,
encrypted_api_key: encryptedApiKey,
base_url: payload.baseUrl ?? null,
}),
});
return parseJsonOrThrow<ProviderTestResult>(response);
});
}
export async function listProviderModels(payload: {
providerType: string;
apiKey: string;
baseUrl?: string | null;
}): Promise<ProviderModelInfo[]> {
return withApiKeyEncryptionRetry(payload.apiKey, async (encryptedApiKey) => {
const response = await authFetch("/api/providers/models", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider_type: payload.providerType,
encrypted_api_key: encryptedApiKey,
base_url: payload.baseUrl ?? null,
}),
});
return parseJsonOrThrow<ProviderModelInfo[]>(response);
});
}

View file

@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import type { ChatSearch } from "@/app/routes/chat";
import {
type DeletedModelRef,
type ExternalModelOption,
@ -9,22 +10,22 @@ import {
ModelSelector,
} from "@/components/assistant-ui/model-selector";
import { Thread } from "@/components/assistant-ui/thread";
import { useSidebar } from "@/components/ui/sidebar";
import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
import { NativeModelChip } from "@/features/native-intents/components/native-model-chip";
import { NativeModelDropOverlay } from "@/features/native-intents/components/native-model-drop-overlay";
import { useNativeIntentStore } from "@/features/native-intents/store";
import type { NativeIntent } from "@/features/native-intents/types";
import { useChooseNativeModel } from "@/features/native-intents/use-native-dialogs";
import { useNativeModelDrop } from "@/features/native-intents/use-native-drop";
import { useNativePathLeasesSupported } from "@/features/native-intents/use-native-readiness";
import { useNativeIntentStore } from "@/features/native-intents/store";
import type { NativeIntent } from "@/features/native-intents/types";
import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { isTauri } from "@/lib/api-base";
import { cn } from "@/lib/utils";
import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { useSidebar } from "@/components/ui/sidebar";
import { CustomizeIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import {
type ReactElement,
memo,
@ -35,18 +36,21 @@ import {
useState,
} from "react";
import { toast } from "@/lib/toast";
import type { ChatSearch } from "@/app/routes/chat";
import { listLocalModels } from "./api/chat-api";
import { ChatSettingsPanel } from "./chat-settings-sheet";
import { CopyableErrorChip } from "@/components/ui/copyable-error-chip";
import { ContextUsageBar } from "./components/context-usage-bar";
import { ModelLoadInlineStatus } from "./components/model-load-status";
import { db } from "./db";
import {
buildExternalModelId,
isExternalModelId,
parseExternalModelId,
} from "./external-providers";
import { useChatModelRuntime } from "./hooks/use-chat-model-runtime";
import {
clearTrainingCompareHandoff,
getTrainingCompareHandoff,
} from "./lib/training-compare-handoff";
import {
clampReasoningEffortToLevels,
getExternalReasoningCapabilities,
@ -54,11 +58,6 @@ import {
providerSupportsBuiltinCodeExecution,
providerSupportsBuiltinWebSearch,
} from "./provider-capabilities";
import { useChatModelRuntime } from "./hooks/use-chat-model-runtime";
import {
clearTrainingCompareHandoff,
getTrainingCompareHandoff,
} from "./lib/training-compare-handoff";
import { ChatRuntimeProvider } from "./runtime-provider";
import {
type CompareHandle,
@ -76,6 +75,12 @@ import {
import { useExternalProvidersStore } from "./stores/external-providers-store";
import { buildChatTourSteps } from "./tour";
import type { ChatView, MessageRecord } from "./types";
import {
getStoredChatThread,
isExpectedBackgroundChatStorageError,
listStoredChatMessages,
listStoredChatThreads,
} from "./utils/chat-history-storage";
type LoraCandidate = {
id: string;
@ -322,15 +327,15 @@ const LoraCompareContent = memo(function LoraCompareContent({
useEffect(() => {
let isActive = true;
db.threads
.where("pairId")
.equals(pairId)
.toArray()
.then((threads) => {
if (!isActive) return;
setBaseThreadId(threads.find((t) => t.modelType === "base")?.id);
setLoraThreadId(threads.find((t) => t.modelType === "lora")?.id);
});
listStoredChatThreads({ pairId }).then((threads) => {
if (!isActive) return;
setBaseThreadId(threads.find((t) => t.modelType === "base")?.id);
setLoraThreadId(threads.find((t) => t.modelType === "lora")?.id);
}).catch((error) => {
if (!isExpectedBackgroundChatStorageError(error)) {
throw error;
}
});
return () => {
isActive = false;
};
@ -471,23 +476,21 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
useEffect(() => {
let isActive = true;
db.threads
.where("pairId")
.equals(pairId)
.toArray()
.then((threads) => {
if (!isActive) return;
setModel1ThreadId(
threads.find(
(t) => t.modelType === "model1" || t.modelType === "base",
)?.id,
);
setModel2ThreadId(
threads.find(
(t) => t.modelType === "model2" || t.modelType === "lora",
)?.id,
);
});
listStoredChatThreads({ pairId }).then((threads) => {
if (!isActive) return;
setModel1ThreadId(
threads.find((t) => t.modelType === "model1" || t.modelType === "base")
?.id,
);
setModel2ThreadId(
threads.find((t) => t.modelType === "model2" || t.modelType === "lora")
?.id,
);
}).catch((error) => {
if (!isExpectedBackgroundChatStorageError(error)) {
throw error;
}
});
return () => {
isActive = false;
};
@ -565,6 +568,9 @@ export function ChatPage(): ReactElement {
const settingsOpen = useChatRuntimeStore((s) => s.settingsPanelOpen);
const setSettingsOpen = useChatRuntimeStore((s) => s.setSettingsPanelOpen);
const hydratePersistedSettings = useChatRuntimeStore(
(s) => s.hydratePersistedSettings,
);
const externalProviders = useExternalProvidersStore((s) => s.providers);
const connectionsEnabled = useExternalProvidersStore(
(s) => s.connectionsEnabled,
@ -572,13 +578,16 @@ export function ChatPage(): ReactElement {
const setExternalProviders = useExternalProvidersStore((s) => s.setProviders);
const externalProvidersForChat = connectionsEnabled ? externalProviders : [];
useEffect(() => {
void hydratePersistedSettings();
}, [hydratePersistedSettings]);
useEffect(() => {
const threadId = search.thread;
if (!threadId) return;
let canceled = false;
void db.threads
.get(threadId)
void getStoredChatThread(threadId)
.then((thread) => {
if (canceled || thread) return;
useChatRuntimeStore.getState().setActiveThreadId(null);
@ -1041,12 +1050,9 @@ export function ChatPage(): ReactElement {
void (async () => {
let showImageCompatibilityWarning = false;
if (view.mode === "single" && activeThreadId) {
const thread = await db.threads.get(activeThreadId);
const thread = await getStoredChatThread(activeThreadId);
if (thread?.modelId && thread.modelId !== value) {
const messages = await db.messages
.where("threadId")
.equals(activeThreadId)
.toArray();
const messages = await listStoredChatMessages(activeThreadId);
if (messages.length > 0) {
const hasImage = messages.some(messageHasImage);
const targetModel = modelsFromStore.find(
@ -1131,17 +1137,22 @@ export function ChatPage(): ReactElement {
const threadId =
saved.thread ?? useChatRuntimeStore.getState().activeThreadId;
if (threadId) {
void db.messages
.where("threadId")
.equals(threadId)
.reverse()
.first()
void listStoredChatMessages(threadId)
.then(
(messages) =>
[...messages].sort((a, b) => b.createdAt - a.createdAt)[0],
)
.then((msg) => {
const metadata = msg?.metadata as Record<string, unknown> | undefined;
const usage = metadata?.contextUsage as ReturnType<
typeof useChatRuntimeStore.getState
>["contextUsage"];
if (usage) useChatRuntimeStore.getState().setContextUsage(usage);
})
.catch((error) => {
if (!isExpectedBackgroundChatStorageError(error)) {
throw error;
}
});
}
}, [navigate]);
@ -1464,7 +1475,7 @@ export function ChatPage(): ReactElement {
) : null}
{!settingsOpen && (
<Tooltip>
<TooltipPrimitive.Trigger asChild>
<TooltipPrimitive.Trigger asChild={true}>
<button
type="button"
onClick={() => setSettingsOpen(true)}

View file

@ -44,6 +44,11 @@ import {
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import {
@ -53,17 +58,12 @@ import {
LayoutAlignRightIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { ChevronDown } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Fragment, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "@/lib/toast";
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import { OpenAICodeExecSection } from "./components/openai-code-exec-section";
import {
type ExternalProviderConfig,
getExternalProviderApiKey,
@ -71,178 +71,32 @@ import {
supportsProviderPromptCaching,
} from "./external-providers";
import {
applyPresetParams,
BUILTIN_PRESET_NAMES,
BUILTIN_PRESETS,
defaultInferenceParams,
BUILTIN_PRESET_NAMES,
applyPresetParams,
getBuiltinVariantName,
getOrderedPresets,
getPresetOwnedConfigKey,
getPresetSaveState,
getPresetSource,
getUniquePresetName,
isSamePresetConfig,
normalizeCustomPresets,
toPresetParams,
type Preset,
} from "./presets/preset-policy";
import { OpenAICodeExecSection } from "./components/openai-code-exec-section";
import {
EXTERNAL_MAX_OUTPUT_TOKENS,
type ProviderCapabilities,
getExternalMinOutputTokens,
providerSupportsBuiltinCodeExecution,
type ProviderCapabilities,
} from "./provider-capabilities";
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import type { InferenceParams } from "./types/runtime";
export { defaultInferenceParams, type Preset } from "./presets/preset-policy";
export type { InferenceParams } from "./types/runtime";
interface LegacySystemPromptTemplate {
name: string;
content: string;
}
const CHAT_PRESETS_KEY = "unsloth_chat_custom_presets";
const CHAT_ACTIVE_PRESET_KEY = "unsloth_chat_active_preset";
const LEGACY_CHAT_SYSTEM_PROMPTS_KEY = "unsloth_chat_system_prompts";
const LEGACY_CHAT_SYSTEM_PROMPTS_MIGRATED_KEY =
"unsloth_chat_system_prompts_migrated";
function canUseStorage(): boolean {
return typeof window !== "undefined";
}
function saveCustomPresets(presets: Preset[]): void {
if (!canUseStorage()) return;
try {
localStorage.setItem(CHAT_PRESETS_KEY, JSON.stringify(presets));
} catch {
// ignore
}
}
function migrateLegacySystemPromptTemplates(presets: Preset[]): Preset[] {
if (!canUseStorage()) return presets;
try {
const raw = localStorage.getItem(LEGACY_CHAT_SYSTEM_PROMPTS_KEY);
if (!raw) return presets;
if (localStorage.getItem(LEGACY_CHAT_SYSTEM_PROMPTS_MIGRATED_KEY) === raw) {
return presets;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch {
localStorage.removeItem(LEGACY_CHAT_SYSTEM_PROMPTS_KEY);
localStorage.setItem(LEGACY_CHAT_SYSTEM_PROMPTS_MIGRATED_KEY, raw);
return presets;
}
if (!Array.isArray(parsed)) {
localStorage.removeItem(LEGACY_CHAT_SYSTEM_PROMPTS_KEY);
localStorage.setItem(LEGACY_CHAT_SYSTEM_PROMPTS_MIGRATED_KEY, raw);
return presets;
}
const usedNames = new Set([
...BUILTIN_PRESETS.map((preset) => preset.name),
...presets.map((preset) => preset.name),
]);
const seenImportedConfigKeys = new Set(
[...BUILTIN_PRESETS, ...presets].map((preset) =>
getPresetOwnedConfigKey(preset.params),
),
);
const importedPresets = parsed
.filter((item): item is LegacySystemPromptTemplate => {
if (!item || typeof item !== "object") return false;
const maybe = item as Partial<LegacySystemPromptTemplate>;
return (
typeof maybe.name === "string" && typeof maybe.content === "string"
);
})
.map((template) => ({
template,
importedParams: {
...defaultInferenceParams,
systemPrompt: template.content,
},
}))
.filter(({ importedParams }) => {
const configKey = getPresetOwnedConfigKey(importedParams);
if (seenImportedConfigKeys.has(configKey)) return false;
seenImportedConfigKeys.add(configKey);
return true;
})
.map(({ template, importedParams }) => ({
name: getUniquePresetName(`${template.name} Prompt`, usedNames),
params: importedParams,
}));
if (importedPresets.length === 0) {
localStorage.removeItem(LEGACY_CHAT_SYSTEM_PROMPTS_KEY);
localStorage.setItem(LEGACY_CHAT_SYSTEM_PROMPTS_MIGRATED_KEY, raw);
return presets;
}
const mergedPresets = normalizeCustomPresets([
...presets,
...importedPresets,
]);
saveCustomPresets(mergedPresets);
try {
localStorage.setItem(LEGACY_CHAT_SYSTEM_PROMPTS_MIGRATED_KEY, raw);
localStorage.removeItem(LEGACY_CHAT_SYSTEM_PROMPTS_KEY);
} catch {
// ignore cleanup failure after successful import write
}
return mergedPresets;
} catch {
return presets;
}
}
function loadSavedCustomPresets(): Preset[] {
if (!canUseStorage()) return [];
try {
const raw = localStorage.getItem(CHAT_PRESETS_KEY);
if (!raw) {
return migrateLegacySystemPromptTemplates([]);
}
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
return migrateLegacySystemPromptTemplates([]);
}
const presets = parsed
.filter((item): item is Preset => {
if (!item || typeof item !== "object") return false;
const maybe = item as Partial<Preset>;
return typeof maybe.name === "string" && !!maybe.params;
})
.map((preset) => ({
name: preset.name.trim(),
params: {
...defaultInferenceParams,
...preset.params,
},
}))
.filter((preset) => preset.name.length > 0);
const normalized = normalizeCustomPresets(presets);
if (JSON.stringify(normalized) !== JSON.stringify(presets)) {
saveCustomPresets(normalized);
}
return migrateLegacySystemPromptTemplates(normalized);
} catch {
return migrateLegacySystemPromptTemplates([]);
}
}
function loadSavedActivePreset(): string {
if (!canUseStorage()) return "Default";
try {
return localStorage.getItem(CHAT_ACTIVE_PRESET_KEY) ?? "Default";
} catch {
return "Default";
}
}
export function InfoHint({ children }: { children: ReactNode }) {
return (
<Tooltip>
@ -607,6 +461,11 @@ export function ChatSettingsPanel({
(s) => s.setActivePresetSource,
);
const activePresetSource = useChatRuntimeStore((s) => s.activePresetSource);
const customPresets = useChatRuntimeStore((s) => s.customPresets);
const setCustomPresets = useChatRuntimeStore((s) => s.setCustomPresets);
const activePreset = useChatRuntimeStore((s) => s.activePreset);
const setActivePreset = useChatRuntimeStore((s) => s.setActivePreset);
const settingsHydrated = useChatRuntimeStore((s) => s.settingsHydrated);
const ctxDisplayValue = customContextLength ?? ggufContextLength ?? "";
const ctxMaxValue = ggufNativeContextLength ?? ggufContextLength ?? null;
@ -625,15 +484,7 @@ export function ChatSettingsPanel({
(s) => s.setChatTemplateOverride,
);
const templateDirty = chatTemplateOverride !== loadedChatTemplateOverride;
const [customPresets, setCustomPresets] = useState<Preset[]>(() =>
loadSavedCustomPresets(),
);
const [activePreset, setActivePreset] = useState(() =>
loadSavedActivePreset(),
);
const [presetNameInput, setPresetNameInput] = useState(() =>
loadSavedActivePreset(),
);
const [presetNameInput, setPresetNameInput] = useState(activePreset);
const [systemPromptEditorOpen, setSystemPromptEditorOpen] = useState(false);
const [systemPromptDraft, setSystemPromptDraft] = useState("");
const [activePresetBaseline, setActivePresetBaseline] = useState(params);
@ -713,6 +564,9 @@ export function ChatSettingsPanel({
}
function applyPreset(name: string) {
if (!settingsHydrated) {
return;
}
const p = presets.find((pr) => pr.name === name);
if (p) {
onParamsChange({
@ -720,17 +574,13 @@ export function ChatSettingsPanel({
});
setActivePreset(name);
setActivePresetSource(getPresetSource(name));
if (canUseStorage()) {
try {
localStorage.setItem(CHAT_ACTIVE_PRESET_KEY, name);
} catch {
// ignore
}
}
}
}
function savePresetWithName(rawName: string) {
if (!settingsHydrated) {
return;
}
const trimmed = rawName.trim();
if (!trimmed) {
toast.error("Enter a preset name");
@ -743,28 +593,21 @@ export function ChatSettingsPanel({
const saveName = BUILTIN_PRESET_NAMES.has(trimmed)
? getBuiltinVariantName(trimmed, usedNames)
: trimmed;
setCustomPresets((prev) => {
const next = prev.filter((p) => p.name !== saveName);
const merged = [
...next,
{ name: saveName, params: toPresetParams(params) },
];
saveCustomPresets(merged);
return merged;
});
if (canUseStorage()) {
try {
localStorage.setItem(CHAT_ACTIVE_PRESET_KEY, saveName);
} catch {
// ignore
}
}
const next = customPresets.filter((p) => p.name !== saveName);
const merged = [
...next,
{ name: saveName, params: toPresetParams(params) },
];
setCustomPresets(merged);
setActivePreset(saveName);
setActivePresetSource("custom");
setPresetNameInput(saveName);
}
function deletePreset(name: string) {
if (!settingsHydrated) {
return;
}
const hasCustomPreset = customPresets.some(
(preset) => preset.name === name,
);
@ -774,11 +617,8 @@ export function ChatSettingsPanel({
const fallbackPreset =
BUILTIN_PRESETS.find((preset) => preset.name === "Default") ??
null;
setCustomPresets((prev) => {
const next = prev.filter((preset) => preset.name !== name);
saveCustomPresets(next);
return next;
});
const next = customPresets.filter((preset) => preset.name !== name);
setCustomPresets(next);
if (activePreset === name) {
if (fallbackPreset) {
onParamsChange({
@ -786,13 +626,6 @@ export function ChatSettingsPanel({
});
setActivePreset(fallbackPreset.name);
setActivePresetSource("builtin-default");
if (canUseStorage()) {
try {
localStorage.setItem(CHAT_ACTIVE_PRESET_KEY, fallbackPreset.name);
} catch {
// ignore
}
}
}
}
}
@ -814,6 +647,9 @@ export function ChatSettingsPanel({
}, [activePresetSource, params]);
useEffect(() => {
if (!settingsHydrated) {
return;
}
if (presets.some((preset) => preset.name === activePreset)) {
const expectedSource = getPresetSource(activePreset);
if (
@ -826,18 +662,13 @@ export function ChatSettingsPanel({
}
setActivePreset("Default");
setActivePresetSource("builtin-default");
if (canUseStorage()) {
try {
localStorage.setItem(CHAT_ACTIVE_PRESET_KEY, "Default");
} catch {
// ignore
}
}
}, [
activePreset,
activePresetSource,
presets,
setActivePreset,
setActivePresetSource,
settingsHydrated,
]);
useEffect(() => {
@ -1152,7 +983,11 @@ export function ChatSettingsPanel({
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter" && presetSaveState.canSubmit) {
if (
e.key === "Enter" &&
settingsHydrated &&
presetSaveState.canSubmit
) {
e.preventDefault();
savePresetWithName(presetNameInput);
}
@ -1194,7 +1029,14 @@ export function ChatSettingsPanel({
{presets.map((p, index) => (
<Fragment key={p.name}>
<DropdownMenuItem
onSelect={() => applyPreset(p.name)}
disabled={!settingsHydrated}
onSelect={(event) => {
if (!settingsHydrated) {
event.preventDefault();
return;
}
applyPreset(p.name);
}}
className="flex min-h-9 items-center px-3 py-0 text-[13px] font-medium leading-[1.4] tracking-nav"
>
{p.name}
@ -1211,7 +1053,7 @@ export function ChatSettingsPanel({
<Button
type="button"
onClick={() => savePresetWithName(presetNameInput)}
disabled={!presetSaveState.canSubmit}
disabled={!(settingsHydrated && presetSaveState.canSubmit)}
variant={presetSaveState.isSaveReady ? "default" : "outline"}
size="sm"
className={cn(
@ -1227,7 +1069,7 @@ export function ChatSettingsPanel({
<Button
type="button"
onClick={() => deletePreset(activePreset)}
disabled={!activeCustomPreset}
disabled={!(settingsHydrated && activeCustomPreset)}
variant="outline"
size="sm"
className="h-9 w-full rounded-[10px] text-[13px] font-medium tracking-nav text-muted-foreground"

View file

@ -50,13 +50,16 @@ import {
listOpenAIContainers,
type OpenAIContainerSummary,
} from "../api/openai-containers";
import { db } from "../db";
import { CHAT_HISTORY_UPDATED_EVENT } from "../api/chat-api";
import type { ExternalProviderConfig } from "../external-providers";
import { useLiveQuery } from "../db";
import { ensureThreadRecord } from "../runtime-provider";
import { InfoHint } from "../chat-settings-sheet";
import {
getStoredChatThread,
listStoredChatThreads,
updateStoredChatThread,
} from "../utils/chat-history-storage";
const AUTO_OPTION_VALUE = "__auto__";
const DEFAULT_TTL_MINUTES = 20;
const TTL_MIN = 1;
const TTL_MAX = 20; // OpenAI hard cap on expires_after.minutes
@ -141,11 +144,34 @@ export function OpenAICodeExecSection({
useState<OpenAIContainerSummary | null>(null);
const [deleting, setDeleting] = useState(false);
const thread = useLiveQuery(
async () => (activeThreadId ? db.threads.get(activeThreadId) : undefined),
[activeThreadId],
const [activeContainerId, setActiveContainerId] = useState<string | null>(
null,
);
const activeContainerId = thread?.openaiCodeExecContainerId ?? null;
useEffect(() => {
let cancelled = false;
async function loadActiveContainer() {
if (!activeThreadId) {
setActiveContainerId(null);
return;
}
const thread = await getStoredChatThread(activeThreadId).catch(
() => undefined,
);
if (!cancelled) {
setActiveContainerId(thread?.openaiCodeExecContainerId ?? null);
}
}
void loadActiveContainer();
window.addEventListener(CHAT_HISTORY_UPDATED_EVENT, loadActiveContainer);
return () => {
cancelled = true;
window.removeEventListener(
CHAT_HISTORY_UPDATED_EVENT,
loadActiveContainer,
);
};
}, [activeThreadId]);
// Hide just-deleted containers even if OpenAI's list still returns them.
// This is the single chokepoint — every downstream view (sorted picker,
@ -256,7 +282,7 @@ export function OpenAICodeExecSection({
// what feels "most recent" from the user's perspective.
//
// We eagerly materialize the thread row via `ensureThreadRecord` so
// the bind actually lands in Dexie before the user has sent a first
// the bind lands before the user has sent a first
// message. This does NOT create anything at OpenAI — only a local
// ThreadRecord — so it does not bypass the user's expectation that
// a fresh OpenAI container is not created until first send.
@ -284,7 +310,7 @@ export function OpenAICodeExecSection({
threadId: activeThreadId,
modelType: "base",
});
await db.threads.update(activeThreadId, {
await updateStoredChatThread(activeThreadId, {
openaiCodeExecContainerId: candidate.id,
});
} catch {
@ -314,10 +340,10 @@ export function OpenAICodeExecSection({
// actually lands when the user hasn't sent a message yet.
try {
await ensureThreadRecord({ threadId: activeThreadId, modelType: "base" });
const affected = await db.threads.update(activeThreadId, {
const updated = await updateStoredChatThread(activeThreadId, {
openaiCodeExecContainerId: value,
});
if (affected === 0) {
if (!updated) {
toast.error("Could not update thread.");
}
} catch (err) {
@ -373,18 +399,14 @@ export function OpenAICodeExecSection({
}, 5000);
// Auto-bind the just-created container to the active thread.
// ensureThreadRecord first so the bind lands even when the user
// creates a container before sending the first message — without
// it, db.threads.update silently affects 0 rows and the chat
// adapter falls back to cross-thread inheritance / lazy-create,
// which can pick a stale container that fails with "container
// does not exist" on the first turn.
// creates a container before sending the first message.
if (activeThreadId) {
try {
await ensureThreadRecord({
threadId: activeThreadId,
modelType: "base",
});
await db.threads.update(activeThreadId, {
await updateStoredChatThread(activeThreadId, {
openaiCodeExecContainerId: created.id,
});
} catch {
@ -422,12 +444,12 @@ export function OpenAICodeExecSection({
return next;
});
// Clear any thread bindings pointing at the now-deleted id.
const affected = await db.threads
.filter((t) => t.openaiCodeExecContainerId === id)
.toArray();
const affected = (
await listStoredChatThreads({ includeArchived: true })
).filter((t) => t.openaiCodeExecContainerId === id);
await Promise.all(
affected.map((t) =>
db.threads.update(t.id, { openaiCodeExecContainerId: null }),
updateStoredChatThread(t.id, { openaiCodeExecContainerId: null }),
),
);
toast.success(`Deleted container ${name || id}`);

View file

@ -5,7 +5,11 @@ import Dexie, { type EntityTable, liveQuery } from "dexie";
import { useEffect, useRef, useState } from "react";
import type { MessageRecord, ThreadRecord } from "./types";
const db = new Dexie("unsloth-chat") as Dexie & {
// Legacy browser-only chat storage. Replaced by studio.db (see
// chat-history-storage.ts), kept read-only for the one-shot import path.
export const DEXIE_DB_NAME = "unsloth-chat";
const db = new Dexie(DEXIE_DB_NAME) as Dexie & {
threads: EntityTable<ThreadRecord, "id">;
messages: EntityTable<MessageRecord, "id">;
};

View file

@ -1,9 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useEffect, useState } from "react";
import { db } from "../db";
import type { MessageRecord, ThreadRecord } from "../types";
import { useEffect, useRef, useState } from "react";
import { CHAT_HISTORY_UPDATED_EVENT } from "../api/chat-api";
import type { MessageRecord } from "../types";
import {
listStoredChatMessages,
listStoredChatThreads,
} from "../utils/chat-history-storage";
export interface ChatSearchItem {
type: "single" | "compare";
@ -15,6 +19,7 @@ export interface ChatSearchItem {
const THREAD_LIMIT = 200;
const PREVIEW_MAX = 120;
const SEARCH_REBUILD_DEBOUNCE_MS = 300;
function extractText(message: MessageRecord): string {
const content = message.content;
@ -23,7 +28,10 @@ function extractText(message: MessageRecord): string {
for (const part of content) {
if (!part || typeof part !== "object") continue;
const p = part as { type?: string; text?: unknown };
if ((p.type === "text" || p.type === "reasoning") && typeof p.text === "string") {
if (
(p.type === "text" || p.type === "reasoning") &&
typeof p.text === "string"
) {
parts.push(p.text);
}
}
@ -32,18 +40,13 @@ function extractText(message: MessageRecord): string {
function truncate(text: string, max: number): string {
if (text.length <= max) return text;
return text.slice(0, max).trimEnd() + "…";
return `${text.slice(0, max).trimEnd()}`;
}
async function buildIndex(): Promise<ChatSearchItem[]> {
// Fetch all threads newest-first, filter archived in JS, then take top N.
// `archived` is a boolean which Dexie does not index reliably, so we filter
// after the sort instead of using `.where("archived")`.
const all = (await db.threads
.orderBy("createdAt")
.reverse()
.toArray()) as ThreadRecord[];
const active = all.filter((t) => !t.archived).slice(0, THREAD_LIMIT);
const active = (
await listStoredChatThreads({ includeArchived: false })
).slice(0, THREAD_LIMIT);
const itemThreadIds = new Map<
string,
@ -81,15 +84,16 @@ async function buildIndex(): Promise<ChatSearchItem[]> {
}
}
// One query for all messages across all relevant threads, then group by
// threadId in memory. Avoids N sequential awaits.
const allThreadIds = Array.from(itemThreadIds.values()).flatMap(
(e) => e.threadIds,
);
const messages = (await db.messages
.where("threadId")
.anyOf(allThreadIds)
.toArray()) as MessageRecord[];
const storedMessagesByThread = await Promise.all(
allThreadIds.map(async (threadId) => ({
threadId,
messages: await listStoredChatMessages(threadId),
})),
);
const messages = storedMessagesByThread.flatMap((entry) => entry.messages);
const byThreadId = new Map<string, MessageRecord[]>();
for (const m of messages) {
@ -131,6 +135,7 @@ export function useChatSearchIndex(enabled: boolean): {
} {
const [items, setItems] = useState<ChatSearchItem[]>([]);
const [loading, setLoading] = useState(false);
const requestSeqRef = useRef(0);
useEffect(() => {
if (!enabled) {
@ -139,19 +144,42 @@ export function useChatSearchIndex(enabled: boolean): {
return;
}
let cancelled = false;
setLoading(true);
buildIndex()
.then((result) => {
if (!cancelled) setItems(result);
})
.catch(() => {
if (!cancelled) setItems([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const run = () => {
const seq = ++requestSeqRef.current;
setLoading(true);
buildIndex()
.then((result) => {
// Drop out-of-order responses so a slower rebuild can't clobber
// a fresher one.
if (cancelled || seq !== requestSeqRef.current) return;
setItems(result);
})
.catch(() => {
if (cancelled || seq !== requestSeqRef.current) return;
setItems([]);
})
.finally(() => {
if (cancelled || seq !== requestSeqRef.current) return;
setLoading(false);
});
};
const scheduleRebuild = () => {
if (debounceTimer !== null) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
debounceTimer = null;
if (!cancelled) run();
}, SEARCH_REBUILD_DEBOUNCE_MS);
};
run();
window.addEventListener(CHAT_HISTORY_UPDATED_EVENT, scheduleRebuild);
return () => {
cancelled = true;
if (debounceTimer !== null) clearTimeout(debounceTimer);
window.removeEventListener(CHAT_HISTORY_UPDATED_EVENT, scheduleRebuild);
};
}, [enabled]);

View file

@ -1,10 +1,22 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { db, useLiveQuery } from "../db";
import { useEffect, useState } from "react";
import { CHAT_HISTORY_UPDATED_EVENT } from "../api/chat-api";
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
import type { ThreadRecord } from "../types";
import { markChatThreadDeleted } from "../utils/chat-thread-tombstones";
import {
deleteStoredChatThreads,
isExpectedBackgroundChatStorageError,
listStoredChatThreads,
listStoredChatThreadsWithMessages,
updateStoredChatThread,
} from "../utils/chat-history-storage";
import {
markChatThreadsDeleted,
removeChatThreadTombstones,
} from "../utils/chat-thread-tombstones";
import { notifyChatHistoryUpdated } from "../api/chat-api";
export interface SidebarItem {
type: "single" | "compare";
@ -45,14 +57,57 @@ export function groupThreads(threads: ThreadRecord[]): SidebarItem[] {
return items.sort((a, b) => b.createdAt - a.createdAt);
}
// Streaming fires CHAT_HISTORY_UPDATED_EVENT per chunk. Debounce so
// each quiet window produces at most one O(N) fetch; requestSeq
// discards stale responses.
const SIDEBAR_REFRESH_DEBOUNCE_MS = 300;
export function useChatSidebarItems() {
const allThreads = useLiveQuery(async () => {
const threadIdsWithMessage = new Set(
(await db.messages.orderBy("threadId").uniqueKeys()) as string[],
);
const rows = await db.threads.orderBy("createdAt").reverse().toArray();
return rows.filter((t) => !t.archived && threadIdsWithMessage.has(t.id));
const [allThreads, setAllThreads] = useState<ThreadRecord[]>([]);
useEffect(() => {
let cancelled = false;
let pendingTimer: ReturnType<typeof setTimeout> | null = null;
let requestSeq = 0;
async function doLoad(seq: number) {
try {
const threads = await listStoredChatThreadsWithMessages({
includeArchived: false,
});
// Discard the response if a newer request was scheduled while we
// were in flight, or if the effect was torn down.
if (cancelled || seq !== requestSeq) return;
setAllThreads(threads);
} catch (error) {
if (isExpectedBackgroundChatStorageError(error)) {
return;
}
if (!cancelled) throw error;
}
}
function load() {
if (pendingTimer !== null) clearTimeout(pendingTimer);
pendingTimer = setTimeout(() => {
pendingTimer = null;
requestSeq += 1;
void doLoad(requestSeq);
}, SIDEBAR_REFRESH_DEBOUNCE_MS);
}
// Initial load fires immediately (no debounce) so the sidebar isn't
// blank for 300ms on mount.
requestSeq += 1;
void doLoad(requestSeq);
window.addEventListener(CHAT_HISTORY_UPDATED_EVENT, load);
return () => {
cancelled = true;
if (pendingTimer !== null) clearTimeout(pendingTimer);
window.removeEventListener(CHAT_HISTORY_UPDATED_EVENT, load);
};
}, []);
const items = groupThreads(allThreads ?? []);
const canCompare = useChatRuntimeStore((s) => Boolean(s.params.checkpoint));
@ -74,19 +129,18 @@ export async function renameChatItem(
if (!trimmed || trimmed === item.title) return;
if (item.type === "single") {
await db.threads.update(item.id, { title: trimmed });
await updateStoredChatThread(item.id, { title: trimmed });
return;
}
const pairThreads = await db.threads
.where("pairId")
.equals(item.id)
.toArray();
await db.transaction("rw", db.threads, async () => {
for (const t of pairThreads) {
await db.threads.update(t.id, { title: trimmed });
}
const threads = await listStoredChatThreads({
pairId: item.id,
includeArchived: true,
});
const threadIds = Array.from(new Set(threads.map((thread) => thread.id)));
await Promise.all(
threadIds.map((id) => updateStoredChatThread(id, { title: trimmed })),
);
}
export async function deleteChatItem(
@ -97,24 +151,26 @@ export async function deleteChatItem(
const threadIds: string[] =
item.type === "single"
? [item.id]
: (await db.threads.where("pairId").equals(item.id).toArray()).map(
(t) => t.id,
);
: (await listStoredChatThreads({ pairId: item.id })).map((t) => t.id);
// Stop any in-flight streams before deleting, so the model doesn't keep
// generating against a thread that no longer exists.
for (const id of threadIds) cancelIfRunning(id);
for (const id of threadIds) markChatThreadDeleted(id);
await db.transaction("rw", db.threads, db.messages, async () => {
for (const id of threadIds) {
await db.messages.where("threadId").equals(id).delete();
await db.threads.delete(id);
}
});
// Optimistic tombstone: hide immediately; roll back on backend error.
markChatThreadsDeleted(threadIds);
notifyChatHistoryUpdated();
if (activeId === item.id) {
useChatRuntimeStore.getState().setActiveThreadId(null);
onSelect({ mode: "single", newThreadNonce: crypto.randomUUID() });
}
try {
await deleteStoredChatThreads(threadIds);
} catch (error) {
removeChatThreadTombstones(threadIds);
notifyChatHistoryUpdated();
throw error;
}
}

View file

@ -13,6 +13,8 @@ export { useChatSearchStore } from "./stores/chat-search-store";
export { useChatModelRuntime } from "./hooks/use-chat-model-runtime";
export { ChatSearchDialog } from "./components/chat-search-dialog";
export { setTrainingCompareHandoff } from "./lib/training-compare-handoff";
export { clearAllChats, countAllChats } from "./utils/clear-all-chats";
export { downloadChatExport } from "./utils/export-chat-history";
export {
deleteChatItem,
renameChatItem,

View file

@ -1,15 +1,17 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { authFetch } from "@/features/auth";
import {
AssistantRuntimeProvider,
type AttachmentAdapter,
type ChatModelAdapter,
type CompleteAttachment,
CompositeAttachmentAdapter,
ExportedMessageRepository,
type ExportedMessageRepositoryItem,
type PendingAttachment,
type LocalRuntimeOptions,
type PendingAttachment,
type ThreadHistoryAdapter,
type ThreadMessage,
WebSpeechDictationAdapter,
@ -32,9 +34,7 @@ import {
} from "react";
import { extractText, getDocumentProxy } from "unpdf";
import { toast } from "sonner";
import { authFetch } from "@/features/auth";
import { createOpenAIStreamAdapter } from "./api/chat-adapter";
import { db } from "./db";
import {
loadConnectionsEnabled,
loadExternalProviders,
@ -49,14 +49,24 @@ import {
readOpenDocumentAttachmentContent,
} from "./open-document";
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import type { MessageRecord, ModelType } from "./types";
import type { MessageRecord, ModelType, ThreadRecord } from "./types";
import {
isChatThreadDeleted,
markChatThreadDeleted,
} from "./utils/chat-thread-tombstones";
import { syncExportedRepositoryToDexie } from "./utils/delete-thread-message";
deleteStoredChatThreads,
ensureStoredChatThread,
getStoredChatThread,
isExpectedBackgroundChatStorageError,
listStoredChatMessages,
listStoredChatThreads,
saveStoredChatMessage,
saveStoredChatThread,
updateStoredChatThread,
} from "./utils/chat-history-storage";
import { isChatThreadDeleted } from "./utils/chat-thread-tombstones";
import { syncExportedRepositoryToBackend } from "./utils/delete-thread-message";
import { getImageInputUnavailableReason } from "./utils/image-input-support";
const pendingHistoryAppendByMessageId = new Map<string, Promise<void>>();
type TitleResponse = {
choices?: Array<{
message?: {
@ -200,7 +210,10 @@ class TextAttachmentAdapter implements AttachmentAdapter {
name: attachment.name,
contentType: attachment.contentType,
content: [
{ type: "text", text: `<attachment name=${attachment.name}>\n${text}\n</attachment>` },
{
type: "text",
text: `<attachment name=${attachment.name}>\n${text}\n</attachment>`,
},
],
status: { type: "complete" },
};
@ -237,9 +250,7 @@ class HtmlAttachmentAdapter implements AttachmentAdapter {
type: "document",
name: attachment.name,
contentType: attachment.contentType,
content: [
{ type: "text", text: `[HTML: ${attachment.name}]\n${text}` },
],
content: [{ type: "text", text: `[HTML: ${attachment.name}]\n${text}` }],
status: { type: "complete" },
};
}
@ -297,7 +308,9 @@ class OpenDocumentAttachmentAdapter implements AttachmentAdapter {
OPEN_DOCUMENT_TEXT_MIME,
].join(",");
async *add({ file }: { file: File }): AsyncGenerator<PendingAttachment, void> {
async *add({
file,
}: { file: File }): AsyncGenerator<PendingAttachment, void> {
const id = crypto.randomUUID();
this.active.add(id);
const attachment = {
@ -329,7 +342,10 @@ class OpenDocumentAttachmentAdapter implements AttachmentAdapter {
this.active.delete(id);
this.content.delete(id);
if (!this.sending.has(id)) {
yield { ...attachment, status: { type: "incomplete", reason: "error" } };
yield {
...attachment,
status: { type: "incomplete", reason: "error" },
};
}
}
}
@ -437,7 +453,9 @@ async function generateTitleWithModel(payload: {
}),
});
const body = (await response.json().catch(() => null)) as TitleResponse | null;
const body = (await response
.json()
.catch(() => null)) as TitleResponse | null;
if (!response.ok) return null;
const raw: string | undefined = body?.choices?.[0]?.message?.content;
if (!raw) return null;
@ -454,13 +472,13 @@ function fallbackTitleFromUserText(userText: string): string {
return cleaned.slice(0, max) + (cleaned.length > max ? "..." : "");
}
function cloneContent(content: ThreadMessage["content"]): ThreadMessage["content"] {
function cloneContent(
content: ThreadMessage["content"],
): ThreadMessage["content"] {
if (typeof content === "string") {
return content;
}
return Array.isArray(content)
? JSON.parse(JSON.stringify(content))
: [];
return Array.isArray(content) ? JSON.parse(JSON.stringify(content)) : [];
}
function cloneAttachments(
@ -489,12 +507,17 @@ function toThreadMessage(m: MessageRecord): ThreadMessage {
};
}
const custom = (m.metadata as Record<string, unknown>) ?? {};
const savedTiming = custom.timing as import("@assistant-ui/react").MessageTiming | undefined;
const savedTiming = custom.timing as
| import("@assistant-ui/react").MessageTiming
| undefined;
return {
id: m.id,
createdAt: new Date(m.createdAt),
role: "assistant" as const,
content: content as Extract<ThreadMessage, { role: "assistant" }>["content"],
content: content as Extract<
ThreadMessage,
{ role: "assistant" }
>["content"],
status: { type: "complete" as const, reason: "unknown" as const },
metadata: {
custom,
@ -519,14 +542,15 @@ export async function ensureThreadRecord({
if (isChatThreadDeleted(threadId)) {
return;
}
const existing = await db.threads.get(threadId);
const existing = (await listStoredChatThreads({ includeArchived: true })).find(
(thread) => thread.id === threadId,
);
if (existing) {
return;
}
const currentModelId =
useChatRuntimeStore.getState().params.checkpoint ?? "";
const record = {
const currentModelId = useChatRuntimeStore.getState().params.checkpoint ?? "";
const record: ThreadRecord = {
id: threadId,
title: "New Chat",
modelType,
@ -537,32 +561,28 @@ export async function ensureThreadRecord({
};
try {
await db.threads.add(record);
await saveStoredChatThread(record);
} catch (error) {
// assistant-ui can issue overlapping first-message persistence calls.
// If another call created the same thread while this one was waiting,
// treat initialization as successful and let the message write continue.
if (await db.threads.get(threadId)) {
const existingAfterRace = await listStoredChatThreads({
includeArchived: true,
}).catch(() => []);
if (existingAfterRace.some((thread) => thread.id === threadId)) {
return;
}
throw error;
}
}
async function deleteThreadRows(threadId: string): Promise<void> {
await db.transaction("rw", db.threads, db.messages, async () => {
await db.messages.where("threadId").equals(threadId).delete();
await db.threads.delete(threadId);
});
}
function createDexieAdapter(
function createStudioDbAdapter(
modelType: ModelType,
pairId?: string,
): unstable_RemoteThreadListAdapter {
return {
async fetch(remoteId: string) {
const thread = await db.threads.get(remoteId);
const thread = await getStoredChatThread(remoteId);
if (!thread) {
throw new Error(`Thread ${remoteId} not found`);
}
@ -574,11 +594,15 @@ function createDexieAdapter(
},
async list() {
const threads = await db.threads
.where("modelType")
.equals(modelType)
.reverse()
.sortBy("createdAt");
let threads: ThreadRecord[];
try {
threads = await listStoredChatThreads({ modelType, pairId });
} catch (error) {
if (!isExpectedBackgroundChatStorageError(error)) {
throw error;
}
threads = [];
}
return {
threads: threads.map((t) => ({
status: (t.archived ? "archived" : "regular") as
@ -596,25 +620,27 @@ function createDexieAdapter(
},
async rename(remoteId: string, newTitle: string) {
await db.threads.update(remoteId, { title: newTitle });
await ensureStoredChatThread(remoteId);
await updateStoredChatThread(remoteId, { title: newTitle });
},
async archive(remoteId: string) {
await db.threads.update(remoteId, { archived: true });
await ensureStoredChatThread(remoteId);
await updateStoredChatThread(remoteId, { archived: true });
},
async unarchive(remoteId: string) {
await db.threads.update(remoteId, { archived: false });
await ensureStoredChatThread(remoteId);
await updateStoredChatThread(remoteId, { archived: false });
},
async delete(remoteId: string) {
markChatThreadDeleted(remoteId);
await deleteThreadRows(remoteId);
await deleteStoredChatThreads([remoteId]);
},
async generateTitle(remoteId: string, messages: readonly ThreadMessage[]) {
const autoTitle = useChatRuntimeStore.getState().autoTitle;
const thread = await db.threads.get(remoteId);
const thread = await getStoredChatThread(remoteId);
const defaultTitle = "New Chat";
function streamTitle(title: string) {
@ -625,14 +651,16 @@ function createDexieAdapter(
}
async function persistTitle(title: string): Promise<void> {
await db.threads.update(remoteId, { title });
await ensureStoredChatThread(remoteId, thread);
await updateStoredChatThread(remoteId, { title });
if (!pairId) return;
const paired = await db.threads
.where("pairId")
.equals(pairId)
.filter((t) => t.id !== remoteId)
.first();
if (paired) await db.threads.update(paired.id, { title });
const paired = (await listStoredChatThreads({ pairId })).find(
(t) => t.id !== remoteId,
);
if (paired) {
await ensureStoredChatThread(paired.id, paired);
await updateStoredChatThread(paired.id, { title });
}
}
if (!thread) {
@ -660,17 +688,18 @@ function createDexieAdapter(
// Compare: wait until both threads done.
if (pairId) {
const paired = await db.threads
.where("pairId")
.equals(pairId)
.filter((t) => t.id !== remoteId)
.first();
const paired = (await listStoredChatThreads({ pairId })).find(
(t) => t.id !== remoteId,
);
if (paired) {
const running = useChatRuntimeStore.getState().runningByThreadId;
if (running[paired.id]) {
setTimeout(() => {
void createDexieAdapter(modelType, pairId).generateTitle(remoteId, messages);
void createStudioDbAdapter(modelType, pairId).generateTitle(
remoteId,
messages,
);
}, 600);
return streamTitle(thread.title || defaultTitle);
}
@ -682,8 +711,7 @@ function createDexieAdapter(
const title =
(await generateTitleWithModel({
userText,
})) ||
fallbackTitleFromUserText(userText);
})) || fallbackTitleFromUserText(userText);
await persistTitle(title);
return streamTitle(title);
@ -696,6 +724,65 @@ function createDexieAdapter(
type StudioRuntimeAdapters = NonNullable<LocalRuntimeOptions["adapters"]>;
function trackHistoryAppend(
messageId: string,
write: Promise<void>,
): Promise<void> {
pendingHistoryAppendByMessageId.set(messageId, write);
const cleanup = () => {
setTimeout(() => {
if (pendingHistoryAppendByMessageId.get(messageId) === write) {
pendingHistoryAppendByMessageId.delete(messageId);
}
}, 30_000);
};
write.then(cleanup, cleanup);
return write;
}
async function waitForRunStartHistoryAppend(
messages: Parameters<ChatModelAdapter["run"]>[0]["messages"],
): Promise<void> {
const lastMessage = messages.at(-1);
if (!lastMessage || lastMessage.role !== "user") {
return;
}
const write = pendingHistoryAppendByMessageId.get(lastMessage.id);
if (!write) {
return;
}
let didPersist = false;
try {
await write;
didPersist = true;
} finally {
if (
didPersist &&
pendingHistoryAppendByMessageId.get(lastMessage.id) === write
) {
pendingHistoryAppendByMessageId.delete(lastMessage.id);
}
}
}
function createPersistedRunAdapter(adapter: ChatModelAdapter): ChatModelAdapter {
return {
...adapter,
async *run(options) {
await waitForRunStartHistoryAppend(options.messages);
const result = adapter.run(options);
if (!result) {
return;
}
if (typeof result === "object" && Symbol.asyncIterator in result) {
yield* result;
return;
}
yield await result;
},
};
}
function useStudioRuntimeAdapters(): StudioRuntimeAdapters {
const aui = useAui();
@ -711,7 +798,15 @@ function useStudioRuntimeAdapters(): StudioRuntimeAdapters {
user: 1,
assistant: 2,
};
const msgs = await db.messages.where("threadId").equals(remoteId).toArray();
let msgs: MessageRecord[];
try {
msgs = await listStoredChatMessages(remoteId);
} catch (error) {
if (!isExpectedBackgroundChatStorageError(error)) {
throw error;
}
msgs = [];
}
msgs.sort((a, b) => {
if (a.createdAt !== b.createdAt) return a.createdAt - b.createdAt;
const aOrder = roleOrder[a.role] ?? 99;
@ -721,16 +816,26 @@ function useStudioRuntimeAdapters(): StudioRuntimeAdapters {
});
// Restore context usage from last assistant message if model matches
const lastAssistant = [...msgs].reverse().find((m) => m.role === "assistant");
const savedUsage = (lastAssistant?.metadata as Record<string, unknown>)?.contextUsage as
| { promptTokens: number; completionTokens: number; totalTokens: number; cachedTokens: number; modelId?: string }
const lastAssistant = [...msgs]
.reverse()
.find((m) => m.role === "assistant");
const savedUsage = (lastAssistant?.metadata as Record<string, unknown>)
?.contextUsage as
| {
promptTokens: number;
completionTokens: number;
totalTokens: number;
cachedTokens: number;
modelId?: string;
}
| undefined;
const store = useChatRuntimeStore.getState();
if (
savedUsage &&
store.ggufContextLength &&
savedUsage.totalTokens <= store.ggufContextLength &&
(!savedUsage.modelId || savedUsage.modelId === store.params.checkpoint)
(!savedUsage.modelId ||
savedUsage.modelId === store.params.checkpoint)
) {
store.setContextUsage(savedUsage);
}
@ -741,14 +846,12 @@ function useStudioRuntimeAdapters(): StudioRuntimeAdapters {
// (old messages without parentId + new messages with), infer
// sequential parents for old messages to preserve the chain.
// Fall back to fromArray for fully legacy threads.
const hasParentIds = msgs.some((m) => "parentId" in m);
const hasParentIds = msgs.some((m) => m.parentId != null);
if (hasParentIds) {
let previousId: string | null = null;
return {
messages: msgs.map((m) => {
const parentId = "parentId" in m
? (m.parentId ?? null)
: previousId;
const parentId = m.parentId != null ? m.parentId : previousId;
previousId = m.id;
return {
parentId,
@ -760,40 +863,49 @@ function useStudioRuntimeAdapters(): StudioRuntimeAdapters {
return ExportedMessageRepository.fromArray(msgs.map(toThreadMessage));
},
async append({ parentId, message }: ExportedMessageRepositoryItem) {
const { remoteId } = await aui.threadListItem().initialize();
if (isChatThreadDeleted(remoteId)) {
await deleteThreadRows(remoteId);
return;
}
// Keep single-chat runtime state in sync once a new chat is first
// persisted. Compare panes intentionally do not write global activeThreadId.
const thread = await db.threads.get(remoteId);
if (thread?.modelType === "base" && !thread.pairId) {
const store = useChatRuntimeStore.getState();
if (store.activeThreadId !== remoteId) {
store.setActiveThreadId(remoteId);
append({ parentId, message }: ExportedMessageRepositoryItem) {
const write = (async () => {
const { remoteId } = await aui.threadListItem().initialize();
if (isChatThreadDeleted(remoteId)) {
await deleteStoredChatThreads([remoteId]);
return;
}
}
const content = cloneContent(message.content);
const attachments =
message.role === "user" ? cloneAttachments(message.attachments) : [];
const custom = message.metadata?.custom;
const existing = await db.messages.get(message.id);
const createdAt =
existing?.createdAt ??
message.createdAt?.getTime?.() ??
Date.now();
await db.messages.put({
id: message.id,
threadId: remoteId,
parentId: parentId ?? null,
role: message.role,
content,
...(attachments.length > 0 && { attachments }),
...(custom && Object.keys(custom).length > 0 && { metadata: custom }),
createdAt,
});
// Keep single-chat runtime state in sync once a new chat is first
// persisted. Compare panes intentionally do not write global activeThreadId.
const thread = await getStoredChatThread(remoteId);
if (thread) {
await ensureStoredChatThread(remoteId, thread);
}
if (thread?.modelType === "base" && !thread.pairId) {
const store = useChatRuntimeStore.getState();
if (store.activeThreadId !== remoteId) {
store.setActiveThreadId(remoteId);
}
}
const content = cloneContent(message.content);
const attachments =
message.role === "user" ? cloneAttachments(message.attachments) : [];
const custom = message.metadata?.custom;
const existingMessage = (await listStoredChatMessages(remoteId)).find(
(storedMessage) => storedMessage.id === message.id,
);
const createdAt =
existingMessage?.createdAt ??
message.createdAt?.getTime?.() ??
Date.now();
await saveStoredChatMessage({
id: message.id,
threadId: remoteId,
parentId: parentId ?? null,
role: message.role,
content,
...(attachments.length > 0 && { attachments }),
...(custom &&
Object.keys(custom).length > 0 && { metadata: custom }),
createdAt,
});
})();
return trackHistoryAppend(message.id, write);
},
}),
[aui],
@ -830,7 +942,11 @@ const chatAdapter = createOpenAIStreamAdapter();
function useRuntimeHook(): ReturnType<typeof useLocalRuntime> {
const adapters = useStudioRuntimeAdapters();
return useLocalRuntime(chatAdapter, { adapters });
const persistedChatAdapter = useMemo(
() => createPersistedRunAdapter(chatAdapter),
[],
);
return useLocalRuntime(persistedChatAdapter, { adapters });
}
function ThreadAutoSwitch({
@ -847,7 +963,10 @@ function ThreadAutoSwitch({
useEffect(() => {
if (!isLoading && mainThreadId !== threadId) {
const switchResult = aui.threads().switchToThread(threadId) as unknown;
if (switchResult && typeof (switchResult as Promise<void>).catch === "function") {
if (
switchResult &&
typeof (switchResult as Promise<void>).catch === "function"
) {
void (switchResult as Promise<void>).catch(() => {
if (syncActiveThreadId) {
useChatRuntimeStore.getState().setActiveThreadId(null);
@ -890,7 +1009,9 @@ function ActiveThreadSync({
enabled,
}: { enabled: boolean }): ReactElement | null {
const mainThreadId = useAuiState(({ threads }) => threads.mainThreadId);
const setActiveThreadId = useChatRuntimeStore((state) => state.setActiveThreadId);
const setActiveThreadId = useChatRuntimeStore(
(state) => state.setActiveThreadId,
);
useEffect(() => {
if (!enabled) {
@ -930,7 +1051,7 @@ function CancelRegistrar(): ReactElement | null {
return null;
}
function ThreadDexieAutosave({
function ThreadBackendAutosave({
modelType,
pairId,
}: {
@ -940,44 +1061,55 @@ function ThreadDexieAutosave({
const aui = useAui();
const saveChainRef = useRef(Promise.resolve());
const saveThread = useCallback(async (threadId: string): Promise<void> => {
const runtime = aui.threads().__internal_getAssistantRuntime?.();
if (!runtime) {
return;
}
const exported = runtime.threads.getById(threadId).export();
if (exported.messages.length === 0) {
return;
}
const { remoteId } = await runtime.threads.getItemById(threadId).initialize();
if (isChatThreadDeleted(remoteId)) {
await deleteThreadRows(remoteId);
return;
}
await syncExportedRepositoryToDexie(remoteId, exported);
if (isChatThreadDeleted(remoteId)) {
await deleteThreadRows(remoteId);
return;
}
if (modelType === "base" && !pairId) {
const store = useChatRuntimeStore.getState();
const activeThreadId = runtime.threads.getState().mainThreadId;
if (activeThreadId === threadId && store.activeThreadId !== remoteId) {
store.setActiveThreadId(remoteId);
const saveThread = useCallback(
async (threadId: string): Promise<void> => {
const runtime = aui.threads().__internal_getAssistantRuntime?.();
if (!runtime) {
return;
}
const exported = runtime.threads.getById(threadId).export();
if (exported.messages.length === 0) {
return;
}
}
}, [aui, modelType, pairId]);
const queueSave = useCallback((threadId: string): void => {
saveChainRef.current = saveChainRef.current
.catch(() => {})
.then(() => saveThread(threadId))
.catch((error) => {
console.error("Failed to autosave chat thread", error);
});
}, [saveThread]);
const { remoteId } = await runtime.threads
.getItemById(threadId)
.initialize();
if (isChatThreadDeleted(remoteId)) {
await deleteStoredChatThreads([remoteId]);
return;
}
await ensureStoredChatThread(remoteId);
await syncExportedRepositoryToBackend(remoteId, exported);
if (isChatThreadDeleted(remoteId)) {
await deleteStoredChatThreads([remoteId]);
return;
}
if (modelType === "base" && !pairId) {
const store = useChatRuntimeStore.getState();
const activeThreadId = runtime.threads.getState().mainThreadId;
if (activeThreadId === threadId && store.activeThreadId !== remoteId) {
store.setActiveThreadId(remoteId);
}
}
},
[aui, modelType, pairId],
);
const queueSave = useCallback(
(threadId: string): void => {
saveChainRef.current = saveChainRef.current
.catch(() => {})
.then(() => saveThread(threadId))
.catch((error) => {
if (!isExpectedBackgroundChatStorageError(error)) {
console.error("Failed to autosave chat thread", error);
}
});
},
[saveThread],
);
useAuiEvent("thread.runEnd", ({ threadId }) => {
queueSave(threadId);
@ -1007,7 +1139,7 @@ export function ChatRuntimeProvider({
}): ReactElement {
const runtime = useRemoteThreadListRuntime({
runtimeHook: useRuntimeHook,
adapter: createDexieAdapter(modelType, pairId),
adapter: createStudioDbAdapter(modelType, pairId),
});
const aui = useAui({});
@ -1015,9 +1147,11 @@ export function ChatRuntimeProvider({
return (
<AssistantRuntimeProvider runtime={runtime} aui={aui}>
<ActiveThreadSync
enabled={modelType === "base" && !pairId && !newThreadNonce && !initialThreadId}
enabled={
modelType === "base" && !pairId && !newThreadNonce && !initialThreadId
}
/>
<ThreadDexieAutosave modelType={modelType} pairId={pairId} />
<ThreadBackendAutosave modelType={modelType} pairId={pairId} />
<CancelRegistrar />
{initialThreadId && (
<ThreadAutoSwitch

View file

@ -1,30 +1,26 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { create } from "zustand";
import { toast } from "@/lib/toast";
import { create } from "zustand";
import {
type ChatPresetSource,
type Preset,
getPresetSource,
} from "../presets/preset-policy";
import {
DEFAULT_INFERENCE_PARAMS,
type ChatLoraSummary,
type ChatModelSummary,
DEFAULT_INFERENCE_PARAMS,
type InferenceParams,
} from "../types/runtime";
import {
getPresetSource,
type ChatPresetSource,
} from "../presets/preset-policy";
loadChatSettingsWithLegacyImport,
savePersistedChatSettingsPatch,
} from "../utils/chat-settings-storage";
const AUTO_TITLE_KEY = "unsloth_chat_auto_title";
const AUTO_HEAL_TOOL_CALLS_KEY = "unsloth_auto_heal_tool_calls";
const MAX_TOOL_CALLS_KEY = "unsloth_max_tool_calls_per_message";
const TOOL_CALL_TIMEOUT_KEY = "unsloth_tool_call_timeout";
const HF_TOKEN_KEY = "unsloth_hf_token";
const INFERENCE_PARAMS_KEY = "unsloth_chat_inference_params";
const CHAT_ACTIVE_PRESET_KEY = "unsloth_chat_active_preset";
const CHAT_ACTIVE_PRESET_SOURCE_KEY = "unsloth_chat_active_preset_source";
export const CHAT_REASONING_ENABLED_KEY = "unsloth_chat_reasoning_enabled";
const REASONING_EFFORT_KEY = "unsloth_reasoning_effort";
const PRESERVE_THINKING_KEY = "unsloth_preserve_thinking";
export const CHAT_TOOLS_ENABLED_KEY = "unsloth_chat_tools_enabled";
export const CHAT_CODE_TOOLS_ENABLED_KEY = "unsloth_chat_code_tools_enabled";
@ -38,27 +34,87 @@ export type ReasoningEffort =
| "max"
| "xhigh";
function loadReasoningEffort(fallback: ReasoningEffort): ReasoningEffort {
if (!canUseStorage()) return fallback;
try {
const raw = localStorage.getItem(REASONING_EFFORT_KEY);
if (
raw === "none" ||
raw === "minimal" ||
raw === "low" ||
raw === "medium" ||
raw === "high" ||
raw === "max" ||
raw === "xhigh"
) {
return raw;
let hasShownSettingsPersistenceWarning = false;
let customPresetsMutationVersion = 0;
let activePresetMutationVersion = 0;
let activePresetSourceMutationVersion = 0;
let settingsHydrationPromise: Promise<void> | null = null;
function warnSettingsPersistenceFailure(): void {
if (hasShownSettingsPersistenceWarning) {
return;
}
hasShownSettingsPersistenceWarning = true;
toast.warning("Chat settings could not be persisted", {
description: "Your changes apply now, but may reset after refresh.",
});
}
// Coalesce setting writes into one pendingPatch (deep merge for nested
// keys), flush on a trailing-edge debounce, flush on beforeunload so a
// pending patch survives tab close. Slider drag ticks now produce one
// HTTP write per quiet window instead of one per tick.
type SettingsPatch = Parameters<typeof savePersistedChatSettingsPatch>[0];
const SETTINGS_DEBOUNCE_MS = 400;
let pendingPatch: SettingsPatch = {};
let pendingTimer: ReturnType<typeof setTimeout> | null = null;
let inflightFlush: Promise<void> = Promise.resolve();
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function mergePatch(into: SettingsPatch, more: SettingsPatch): void {
for (const [key, value] of Object.entries(more)) {
const intoAny = into as Record<string, unknown>;
const prev = intoAny[key];
if (isPlainObject(prev) && isPlainObject(value)) {
intoAny[key] = { ...prev, ...value };
} else {
intoAny[key] = value;
}
return fallback;
} catch {
return fallback;
}
}
let hasShownInferencePersistenceWarning = false;
async function flushSettingsPatch(keepalive = false): Promise<void> {
if (Object.keys(pendingPatch).length === 0) return;
const patch = pendingPatch;
pendingPatch = {};
try {
await savePersistedChatSettingsPatch(patch, { keepalive });
} catch {
const retryPatch: SettingsPatch = {};
mergePatch(retryPatch, patch);
mergePatch(retryPatch, pendingPatch);
pendingPatch = retryPatch;
warnSettingsPersistenceFailure();
}
}
function saveSettingsPatch(patch: SettingsPatch): void {
mergePatch(pendingPatch, patch);
if (pendingTimer !== null) clearTimeout(pendingTimer);
pendingTimer = setTimeout(() => {
pendingTimer = null;
inflightFlush = inflightFlush
.catch(() => undefined)
.then(() => flushSettingsPatch());
}, SETTINGS_DEBOUNCE_MS);
}
// Best-effort flush of any pending patch when the tab closes. keepalive
// lets the PUT outlive the unload; without it the browser cancels the
// fetch and the user's last slider drag is dropped.
if (typeof window !== "undefined") {
window.addEventListener("beforeunload", () => {
if (pendingTimer !== null) clearTimeout(pendingTimer);
if (Object.keys(pendingPatch).length === 0) return;
inflightFlush = inflightFlush
.catch(() => undefined)
.then(() => flushSettingsPatch(true));
});
}
function canUseStorage(): boolean {
return typeof window !== "undefined";
@ -89,27 +145,6 @@ function saveBool(key: string, value: boolean): void {
}
}
function loadInt(key: string, fallback: number): number {
if (!canUseStorage()) return fallback;
try {
const raw = localStorage.getItem(key);
if (raw === null) return fallback;
const parsed = parseInt(raw, 10);
return Number.isNaN(parsed) ? fallback : parsed;
} catch {
return fallback;
}
}
function saveInt(key: string, value: number): void {
if (!canUseStorage()) return;
try {
localStorage.setItem(key, String(value));
} catch {
// ignore
}
}
function loadString(key: string, fallback: string): string {
if (!canUseStorage()) return fallback;
try {
@ -128,83 +163,11 @@ function saveString(key: string, value: string): void {
}
}
function asFiniteNumber(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function asString(value: unknown, fallback: string): string {
return typeof value === "string" ? value : fallback;
}
function asBoolean(value: unknown, fallback: boolean): boolean {
return typeof value === "boolean" ? value : fallback;
}
function loadInferenceParams(): InferenceParams {
if (!canUseStorage()) return DEFAULT_INFERENCE_PARAMS;
try {
const raw = localStorage.getItem(INFERENCE_PARAMS_KEY);
if (!raw) return DEFAULT_INFERENCE_PARAMS;
const parsed = JSON.parse(raw) as Partial<InferenceParams>;
return {
temperature: asFiniteNumber(parsed.temperature, DEFAULT_INFERENCE_PARAMS.temperature),
topP: asFiniteNumber(parsed.topP, DEFAULT_INFERENCE_PARAMS.topP),
topK: asFiniteNumber(parsed.topK, DEFAULT_INFERENCE_PARAMS.topK),
minP: asFiniteNumber(parsed.minP, DEFAULT_INFERENCE_PARAMS.minP),
repetitionPenalty: asFiniteNumber(
parsed.repetitionPenalty,
DEFAULT_INFERENCE_PARAMS.repetitionPenalty,
),
presencePenalty: asFiniteNumber(
parsed.presencePenalty,
DEFAULT_INFERENCE_PARAMS.presencePenalty,
),
maxSeqLength: asFiniteNumber(
parsed.maxSeqLength,
DEFAULT_INFERENCE_PARAMS.maxSeqLength,
),
maxTokens: asFiniteNumber(parsed.maxTokens, DEFAULT_INFERENCE_PARAMS.maxTokens),
systemPrompt: asString(parsed.systemPrompt, DEFAULT_INFERENCE_PARAMS.systemPrompt),
checkpoint: DEFAULT_INFERENCE_PARAMS.checkpoint,
trustRemoteCode: asBoolean(
parsed.trustRemoteCode,
DEFAULT_INFERENCE_PARAMS.trustRemoteCode ?? false,
),
};
} catch {
return DEFAULT_INFERENCE_PARAMS;
}
}
function saveInferenceParams(params: InferenceParams): boolean {
if (!canUseStorage()) return false;
try {
const { checkpoint, ...rest } = params;
void checkpoint;
localStorage.setItem(INFERENCE_PARAMS_KEY, JSON.stringify(rest));
return true;
} catch {
return false;
}
}
function loadPresetSource(): ChatPresetSource {
const activePreset = loadString(CHAT_ACTIVE_PRESET_KEY, "Default");
if (canUseStorage()) {
try {
const raw = localStorage.getItem(CHAT_ACTIVE_PRESET_SOURCE_KEY);
if (raw === "modified") {
return "modified";
}
} catch {
// ignore
}
}
return getPresetSource(activePreset);
}
type ChatRuntimeStore = {
settingsHydrated: boolean;
params: InferenceParams;
customPresets: Preset[];
activePreset: string;
activePresetSource: ChatPresetSource;
models: ChatModelSummary[];
loras: ChatLoraSummary[];
@ -286,9 +249,12 @@ type ChatRuntimeStore = {
} | null;
modelLoading: boolean;
activeNativePathToken: string | null;
hydratePersistedSettings: () => Promise<void>;
setModelLoading: (loading: boolean) => void;
setModelRequiresTrustRemoteCode: (required: boolean) => void;
setParams: (params: InferenceParams) => void;
setCustomPresets: (presets: Preset[]) => void;
setActivePreset: (name: string) => void;
setActivePresetSource: (source: ChatPresetSource) => void;
setModels: (models: ChatModelSummary[]) => void;
setLoras: (loras: ChatLoraSummary[]) => void;
@ -310,10 +276,7 @@ type ChatRuntimeStore = {
setReasoningStyle: (style: ReasoningStyle) => void;
setReasoningEffort: (effort: ReasoningEffort) => void;
setPreserveThinking: (value: boolean) => void;
setToolsEnabled: (
enabled: boolean,
options?: { persist?: boolean },
) => void;
setToolsEnabled: (enabled: boolean, options?: { persist?: boolean }) => void;
setCodeToolsEnabled: (enabled: boolean) => void;
setToolStatus: (status: string | null) => void;
setGeneratingStatus: (status: string | null) => void;
@ -330,14 +293,201 @@ type ChatRuntimeStore = {
setContextUsage: (usage: ChatRuntimeStore["contextUsage"]) => void;
};
export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
params: loadInferenceParams(),
activePresetSource: loadPresetSource(),
type PersistedChatSettings = Awaited<
ReturnType<typeof loadChatSettingsWithLegacyImport>
>;
type PersistedInferenceParams = NonNullable<
PersistedChatSettings["inferenceParams"]
>;
type PersistedInferenceParamKey = keyof PersistedInferenceParams;
type ScalarSettingKey =
| "autoTitle"
| "reasoningEffort"
| "preserveThinking"
| "autoHealToolCalls"
| "maxToolCallsPerMessage"
| "toolCallTimeout";
type PresetHydrationVersions = {
customPresets: number;
activePreset: number;
activePresetSource: number;
};
type SettingsHydrationVersions = {
inferenceParams: Record<PersistedInferenceParamKey, number>;
scalarSettings: Record<ScalarSettingKey, number>;
presets: PresetHydrationVersions;
};
const PERSISTED_INFERENCE_PARAM_KEYS = [
"temperature",
"topP",
"topK",
"minP",
"repetitionPenalty",
"presencePenalty",
"maxSeqLength",
"maxTokens",
"systemPrompt",
"trustRemoteCode",
] as const satisfies readonly PersistedInferenceParamKey[];
const SCALAR_SETTING_KEYS = [
"autoTitle",
"reasoningEffort",
"preserveThinking",
"autoHealToolCalls",
"maxToolCallsPerMessage",
"toolCallTimeout",
] as const satisfies readonly ScalarSettingKey[];
const inferenceParamMutationVersions = Object.fromEntries(
PERSISTED_INFERENCE_PARAM_KEYS.map((key) => [key, 0]),
) as Record<PersistedInferenceParamKey, number>;
const scalarSettingMutationVersions = Object.fromEntries(
SCALAR_SETTING_KEYS.map((key) => [key, 0]),
) as Record<ScalarSettingKey, number>;
function hasKeys(value: object): boolean {
return Object.keys(value).length > 0;
}
function getSettingsHydrationVersions(): SettingsHydrationVersions {
return {
inferenceParams: { ...inferenceParamMutationVersions },
scalarSettings: { ...scalarSettingMutationVersions },
presets: {
customPresets: customPresetsMutationVersion,
activePreset: activePresetMutationVersion,
activePresetSource: activePresetSourceMutationVersion,
},
};
}
function setInferenceParam(
params: InferenceParams,
key: PersistedInferenceParamKey,
value: PersistedInferenceParams[PersistedInferenceParamKey],
): void {
(params as Record<PersistedInferenceParamKey, unknown>)[key] = value;
}
function getChangedInferenceParams(
nextParams: InferenceParams,
currentParams: InferenceParams,
): PersistedInferenceParams {
const changedParams: PersistedInferenceParams = {};
for (const key of PERSISTED_INFERENCE_PARAM_KEYS) {
const nextValue = nextParams[key];
if (Object.is(nextValue, currentParams[key])) {
continue;
}
inferenceParamMutationVersions[key] += 1;
if (nextValue !== undefined) {
setInferenceParam(changedParams as InferenceParams, key, nextValue);
}
}
return changedParams;
}
function getHydratedCustomPresets(
settings: PersistedChatSettings,
state: ChatRuntimeStore,
): Preset[] {
return (
settings.customPresets?.map((preset) => ({
name: preset.name,
params: {
...DEFAULT_INFERENCE_PARAMS,
...preset.params,
},
})) ?? state.customPresets
);
}
function getHydratedPresetState(
settings: PersistedChatSettings,
state: ChatRuntimeStore,
versions: PresetHydrationVersions,
): Partial<
Pick<
ChatRuntimeStore,
"customPresets" | "activePreset" | "activePresetSource"
>
> {
const nextState: Partial<
Pick<
ChatRuntimeStore,
"customPresets" | "activePreset" | "activePresetSource"
>
> = {};
if (customPresetsMutationVersion === versions.customPresets) {
nextState.customPresets = getHydratedCustomPresets(settings, state);
}
if (activePresetMutationVersion === versions.activePreset) {
nextState.activePreset = settings.activePreset ?? state.activePreset;
}
if (activePresetSourceMutationVersion === versions.activePresetSource) {
const activePreset = nextState.activePreset ?? state.activePreset;
nextState.activePresetSource =
settings.activePresetSource ?? getPresetSource(activePreset);
}
return nextState;
}
function getHydratedSettingsState(
settings: PersistedChatSettings,
state: ChatRuntimeStore,
versions: SettingsHydrationVersions,
): Partial<ChatRuntimeStore> {
const nextState: Partial<ChatRuntimeStore> = {};
const params = { ...state.params };
for (const key of PERSISTED_INFERENCE_PARAM_KEYS) {
const value = settings.inferenceParams?.[key];
if (
value !== undefined &&
inferenceParamMutationVersions[key] === versions.inferenceParams[key]
) {
setInferenceParam(params, key, value);
}
}
nextState.params = params;
for (const key of SCALAR_SETTING_KEYS) {
const value = settings[key];
if (
value !== undefined &&
scalarSettingMutationVersions[key] === versions.scalarSettings[key]
) {
(nextState as Record<ScalarSettingKey, unknown>)[key] = value;
}
}
return nextState;
}
function setScalarSettingVersion<K extends ScalarSettingKey>(
key: K,
value: ChatRuntimeStore[K],
currentValue: ChatRuntimeStore[K],
): void {
if (Object.is(value, currentValue)) {
return;
}
scalarSettingMutationVersions[key] += 1;
saveSettingsPatch({ [key]: value });
}
export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
settingsHydrated: false,
params: DEFAULT_INFERENCE_PARAMS,
customPresets: [],
activePreset: "Default",
activePresetSource: getPresetSource("Default"),
models: [],
loras: [],
runningByThreadId: {},
cancelByThreadId: {},
autoTitle: loadBool(AUTO_TITLE_KEY, false),
autoTitle: false,
hfToken: loadString(HF_TOKEN_KEY, ""),
modelsError: null,
activeGgufVariant: null,
@ -349,12 +499,12 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
reasoningAlwaysOn: false,
reasoningEnabled: loadBool(CHAT_REASONING_ENABLED_KEY, true),
reasoningStyle: "enable_thinking",
reasoningEffort: loadReasoningEffort("medium"),
reasoningEffort: "medium",
supportsReasoningOff: false,
reasoningEffortLevels: ["low", "medium", "high"],
lastOpenRouterChosenModel: null,
supportsPreserveThinking: false,
preserveThinking: loadBool(PRESERVE_THINKING_KEY, false),
preserveThinking: false,
supportsTools: false,
supportsBuiltinWebSearch: false,
supportsBuiltinCodeExecution: false,
@ -362,9 +512,9 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
codeToolsEnabled: loadBool(CHAT_CODE_TOOLS_ENABLED_KEY, false),
toolStatus: null,
generatingStatus: null,
autoHealToolCalls: loadBool(AUTO_HEAL_TOOL_CALLS_KEY, true),
maxToolCallsPerMessage: loadInt(MAX_TOOL_CALLS_KEY, 25),
toolCallTimeout: loadInt(TOOL_CALL_TIMEOUT_KEY, 5),
autoHealToolCalls: true,
maxToolCallsPerMessage: 25,
toolCallTimeout: 5,
kvCacheDtype: null,
loadedKvCacheDtype: null,
speculativeType: "auto",
@ -383,24 +533,74 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
contextUsage: null,
modelLoading: false,
activeNativePathToken: null,
hydratePersistedSettings: async () => {
if (get().settingsHydrated) {
return;
}
if (settingsHydrationPromise) {
return settingsHydrationPromise;
}
settingsHydrationPromise = (async () => {
const hydrationVersions = getSettingsHydrationVersions();
try {
const settings = await loadChatSettingsWithLegacyImport();
set((state) => {
if (state.settingsHydrated) {
return state;
}
const nextState: Partial<ChatRuntimeStore> = {
settingsHydrated: true,
...getHydratedPresetState(
settings,
state,
hydrationVersions.presets,
),
...getHydratedSettingsState(settings, state, hydrationVersions),
};
return nextState;
});
} catch {
// Hydrate failed: treat as hydrated-with-defaults so future
// setParams calls reach saveSettingsPatch (which surfaces its
// own toast on real network failure).
warnSettingsPersistenceFailure();
set({ settingsHydrated: true });
} finally {
settingsHydrationPromise = null;
}
})();
return settingsHydrationPromise;
},
setModelLoading: (loading) => set({ modelLoading: loading }),
setModelRequiresTrustRemoteCode: (modelRequiresTrustRemoteCode) =>
set({ modelRequiresTrustRemoteCode }),
setParams: (params) =>
set(() => {
const persisted = saveInferenceParams(params);
if (!persisted && !hasShownInferencePersistenceWarning) {
hasShownInferencePersistenceWarning = true;
toast.warning("Chat settings could not be persisted", {
description:
"Your changes apply now, but may reset after refresh.",
});
set((state) => {
// Bump version unconditionally so a late hydration response
// won't clobber a pre-hydrate user edit; only the HTTP write
// is gated on settingsHydrated.
const changedParams = getChangedInferenceParams(params, state.params);
if (state.settingsHydrated && hasKeys(changedParams)) {
saveSettingsPatch({ inferenceParams: changedParams });
}
return { params };
}),
setCustomPresets: (customPresets) =>
set(() => {
customPresetsMutationVersion += 1;
saveSettingsPatch({ customPresets });
return { customPresets };
}),
setActivePreset: (activePreset) =>
set(() => {
activePresetMutationVersion += 1;
saveSettingsPatch({ activePreset });
return { activePreset };
}),
setActivePresetSource: (activePresetSource) =>
set(() => {
saveString(CHAT_ACTIVE_PRESET_SOURCE_KEY, activePresetSource);
activePresetSourceMutationVersion += 1;
saveSettingsPatch({ activePresetSource });
return { activePresetSource };
}),
setModels: (models) => set({ models }),
@ -429,8 +629,8 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
return { cancelByThreadId: next };
}),
setAutoTitle: (autoTitle) =>
set(() => {
saveBool(AUTO_TITLE_KEY, autoTitle);
set((state) => {
setScalarSettingVersion("autoTitle", autoTitle, state.autoTitle);
return { autoTitle };
}),
setHfToken: (hfToken) =>
@ -447,7 +647,8 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
},
activeGgufVariant: ggufVariant ?? null,
})),
setActiveThreadId: (activeThreadId) => set({ activeThreadId, contextUsage: null }),
setActiveThreadId: (activeThreadId) =>
set({ activeThreadId, contextUsage: null }),
setSettingsPanelOpen: (settingsPanelOpen) => set({ settingsPanelOpen }),
clearCheckpoint: () =>
set((state) => ({
@ -498,19 +699,21 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
set({ lastOpenRouterChosenModel }),
setReasoningStyle: (reasoningStyle) => set({ reasoningStyle }),
setReasoningEffort: (reasoningEffort) =>
set(() => {
if (canUseStorage()) {
try {
localStorage.setItem(REASONING_EFFORT_KEY, reasoningEffort);
} catch {
// ignore
}
}
set((state) => {
setScalarSettingVersion(
"reasoningEffort",
reasoningEffort,
state.reasoningEffort,
);
return { reasoningEffort };
}),
setPreserveThinking: (preserveThinking) =>
set(() => {
saveBool(PRESERVE_THINKING_KEY, preserveThinking);
set((state) => {
setScalarSettingVersion(
"preserveThinking",
preserveThinking,
state.preserveThinking,
);
return { preserveThinking };
}),
setToolsEnabled: (toolsEnabled, options) =>
@ -528,25 +731,38 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
setToolStatus: (toolStatus) => set({ toolStatus }),
setGeneratingStatus: (generatingStatus) => set({ generatingStatus }),
setAutoHealToolCalls: (autoHealToolCalls) =>
set(() => {
saveBool(AUTO_HEAL_TOOL_CALLS_KEY, autoHealToolCalls);
set((state) => {
setScalarSettingVersion(
"autoHealToolCalls",
autoHealToolCalls,
state.autoHealToolCalls,
);
return { autoHealToolCalls };
}),
setMaxToolCallsPerMessage: (maxToolCallsPerMessage) =>
set(() => {
saveInt(MAX_TOOL_CALLS_KEY, maxToolCallsPerMessage);
set((state) => {
setScalarSettingVersion(
"maxToolCallsPerMessage",
maxToolCallsPerMessage,
state.maxToolCallsPerMessage,
);
return { maxToolCallsPerMessage };
}),
setToolCallTimeout: (toolCallTimeout) =>
set(() => {
saveInt(TOOL_CALL_TIMEOUT_KEY, toolCallTimeout);
set((state) => {
setScalarSettingVersion(
"toolCallTimeout",
toolCallTimeout,
state.toolCallTimeout,
);
return { toolCallTimeout };
}),
setKvCacheDtype: (kvCacheDtype) => set({ kvCacheDtype }),
setSpeculativeType: (speculativeType) => set({ speculativeType }),
setSpecDraftNMax: (specDraftNMax) => set({ specDraftNMax }),
setCustomContextLength: (customContextLength) => set({ customContextLength }),
setChatTemplateOverride: (chatTemplateOverride) => set({ chatTemplateOverride }),
setChatTemplateOverride: (chatTemplateOverride) =>
set({ chatTemplateOverride }),
setPendingAudio: (base64, name) =>
set({ pendingAudioBase64: base64, pendingAudioName: name }),
clearPendingAudio: () =>

View file

@ -0,0 +1,773 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import {
buildBackendChatExport,
clearBackendChats,
deleteChatThreads,
getChatMessage,
getChatThread,
batchListChatMessages,
listChatImportLedger,
listChatMessages,
listChatThreads,
notifyChatHistoryUpdated,
recordChatImportLedger,
saveChatMessage,
saveChatThread,
syncChatMessages,
updateChatThread,
} from "../api/chat-api";
import { db, DEXIE_DB_NAME } from "../db";
import type { MessageRecord, ModelType, ThreadRecord } from "../types";
import {
isChatThreadDeleted,
markChatThreadsDeleted,
} from "./chat-thread-tombstones";
type ThreadListArgs = {
modelType?: ModelType;
pairId?: string;
includeArchived?: boolean;
};
// localStorage perf-hint that the Dexie -> studio.db import already
// finished in a previous session. NOT consulted by the import gate
// itself -- the server-side ledger (chat_legacy_imports) is the source
// of truth so a studio.db wipe stays recoverable. The hint only short-
// circuits the listing paths' "should I also surface Dexie threads?"
// branches once the ledger has covered everything.
const LEGACY_CHAT_IMPORT_KEY = "unsloth_chat_legacy_imported_to_studio_db";
let legacyChatImportPromise: Promise<void> | null = null;
interface ExportedChat {
exportedAt: string;
version: 1;
threadCount: number;
threads: unknown[];
messages: unknown[];
}
function canUseStorage(): boolean {
return typeof window !== "undefined";
}
function hasOwn(value: object, key: string): boolean {
return Object.prototype.hasOwnProperty.call(value, key);
}
function isLegacyChatImportDone(): boolean {
if (!canUseStorage()) return true;
try {
return localStorage.getItem(LEGACY_CHAT_IMPORT_KEY) === "true";
} catch {
return false;
}
}
function markLegacyChatImportDone(): void {
if (!canUseStorage()) return;
try {
localStorage.setItem(LEGACY_CHAT_IMPORT_KEY, "true");
} catch {
// ignore
}
}
function matchesThreadListArgs(
thread: ThreadRecord,
args: ThreadListArgs,
): boolean {
return (
!isChatThreadDeleted(thread.id) &&
(!args.pairId || thread.pairId === args.pairId) &&
(!args.modelType || thread.modelType === args.modelType) &&
(args.includeArchived !== false || !thread.archived)
);
}
async function listLegacyThreads(
args: ThreadListArgs,
): Promise<ThreadRecord[]> {
const legacyQuery = args.pairId
? db.threads.where("pairId").equals(args.pairId)
: args.modelType
? db.threads.where("modelType").equals(args.modelType)
: db.threads.toCollection();
return (await legacyQuery.toArray()).filter((thread) =>
matchesThreadListArgs(thread, args),
);
}
function sortMessages(messages: MessageRecord[]): MessageRecord[] {
const roleOrder: Record<string, number> = {
system: 0,
user: 1,
assistant: 2,
};
return [...messages].sort((a, b) => {
if (a.createdAt !== b.createdAt) return a.createdAt - b.createdAt;
const aOrder = roleOrder[a.role] ?? 99;
const bOrder = roleOrder[b.role] ?? 99;
if (aOrder !== bOrder) return aOrder - bOrder;
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
});
}
export function isExpectedBackgroundChatStorageError(error: unknown): boolean {
return (
error instanceof Error &&
(error.message === "Invalid or expired token" ||
error.message === "Not authenticated" ||
error.message === "Request failed (401)" ||
error.message === "Studio isn't running -- please relaunch it.")
);
}
function normalizeLegacyMessages(messages: MessageRecord[]): MessageRecord[] {
let previousId: string | null = null;
return sortMessages(messages).map((message) => {
const parentId = hasOwn(message, "parentId")
? (message.parentId ?? null)
: previousId;
previousId = message.id;
return {
...message,
parentId,
};
});
}
function messageNeedsBackfill(
backend: MessageRecord,
legacy: MessageRecord,
): boolean {
return (
(backend.parentId == null && legacy.parentId != null) ||
(backend.attachments == null && legacy.attachments != null) ||
(backend.metadata == null && legacy.metadata != null)
);
}
function mergeLegacyMessageFields(
backend: MessageRecord,
legacy: MessageRecord,
): MessageRecord {
return {
...backend,
...(backend.parentId == null && legacy.parentId != null
? { parentId: legacy.parentId }
: {}),
...(backend.attachments == null && legacy.attachments != null
? { attachments: legacy.attachments }
: {}),
...(backend.metadata == null && legacy.metadata != null
? { metadata: legacy.metadata }
: {}),
};
}
function mergeMessages(
backendMessages: MessageRecord[],
legacyMessages: MessageRecord[],
options: { includeLegacyOnly?: boolean } = {},
): { messages: MessageRecord[]; shouldSync: boolean } {
const byId = new Map<string, MessageRecord>();
const includeLegacyOnly = options.includeLegacyOnly ?? true;
const backendIds = new Set(
backendMessages
.filter((message) => !isChatThreadDeleted(message.threadId))
.map((message) => message.id),
);
let shouldSync = false;
for (const message of normalizeLegacyMessages(legacyMessages)) {
if (!isChatThreadDeleted(message.threadId)) {
if (includeLegacyOnly || backendIds.has(message.id)) {
byId.set(message.id, message);
}
if (includeLegacyOnly && !backendIds.has(message.id)) shouldSync = true;
}
}
for (const message of backendMessages) {
if (!isChatThreadDeleted(message.threadId)) {
const legacyMessage = byId.get(message.id);
if (legacyMessage && messageNeedsBackfill(message, legacyMessage)) {
byId.set(message.id, mergeLegacyMessageFields(message, legacyMessage));
shouldSync = true;
} else {
byId.set(message.id, message);
}
}
}
return { messages: Array.from(byId.values()), shouldSync };
}
async function importLegacyThread(
thread: ThreadRecord,
): Promise<ThreadRecord | undefined> {
const saved = await saveChatThread(thread);
const legacyMessages = await db.messages
.where("threadId")
.equals(thread.id)
.toArray();
if (legacyMessages.length > 0) {
await syncChatMessages(thread.id, normalizeLegacyMessages(legacyMessages), {
pruneMissing: false,
});
}
return saved;
}
async function backfillLegacyThreadFields(
backendThread: ThreadRecord,
legacyThread: ThreadRecord | undefined,
): Promise<ThreadRecord> {
if (!legacyThread) return backendThread;
const patch: Partial<ThreadRecord> = {};
if (
!backendThread.openaiCodeExecContainerId &&
legacyThread.openaiCodeExecContainerId
) {
patch.openaiCodeExecContainerId = legacyThread.openaiCodeExecContainerId;
}
if (
!backendThread.anthropicCodeExecContainerId &&
legacyThread.anthropicCodeExecContainerId
) {
patch.anthropicCodeExecContainerId =
legacyThread.anthropicCodeExecContainerId;
}
if (Object.keys(patch).length === 0) return backendThread;
try {
return (
(await updateChatThread(backendThread.id, patch)) ?? {
...backendThread,
...patch,
}
);
} catch {
return backendThread;
}
}
// Fast-path: ask IndexedDB whether the "unsloth-chat" database exists
// without opening it. Modern Chromium / Firefox / Safari support this;
// older browsers return undefined and we fall through to the next probe.
async function dexieDbAbsent(): Promise<boolean> {
if (typeof indexedDB === "undefined") return true;
const dbs = (indexedDB as IDBFactory).databases;
if (typeof dbs !== "function") return false;
try {
const list = await dbs.call(indexedDB);
if (!Array.isArray(list)) return false;
return !list.some((entry) => entry?.name === DEXIE_DB_NAME);
} catch {
return false;
}
}
// Fast-path: Dexie exists but is empty. count() reads the IndexedDB
// store metadata, not the rows -- cheap regardless of record count.
async function dexieIsEmpty(): Promise<boolean> {
try {
const [threadCount, messageCount] = await Promise.all([
db.threads.count(),
db.messages.count(),
]);
return threadCount === 0 && messageCount === 0;
} catch {
// Dexie threw (corrupted DB / version mismatch / quota). Returning
// false forces the slow path, which uses the same Dexie under the
// hood; that path will throw too and the import promise gets reset
// so the next caller can retry rather than silently doing nothing.
return false;
}
}
async function importLegacyChatsIfNeeded(): Promise<void> {
// Session-level cache: same tab, repeated sidebar mounts share one
// import. localStorage is NOT consulted here -- the server-side ledger
// is the source of truth so a studio.db wipe still re-triggers the
// import even if the browser kept its old hint.
if (legacyChatImportPromise) return legacyChatImportPromise;
legacyChatImportPromise = (async () => {
// Fast-path: no Dexie database at all. New user, never had the
// browser-only Studio. ~0.1 ms, zero network.
if (await dexieDbAbsent()) {
markLegacyChatImportDone();
return;
}
// Fast-path: Dexie exists but is empty (already migrated long
// ago and Dexie just hasn't been GC'd, or the browser created an
// empty DB for some reason).
if (await dexieIsEmpty()) {
markLegacyChatImportDone();
return;
}
// Slow path: diff Dexie against the server-side ledger and import
// any threads not already recorded.
const [legacyThreads, backendThreads, importedThreadIds] = await Promise.all([
db.threads.toArray(),
listChatThreads({ includeArchived: true }),
listChatImportLedger(),
]);
const backendThreadsById = new Map(
backendThreads.map((thread) => [thread.id, thread]),
);
const unimportedIds: string[] = [];
const unimportedThreads: ThreadRecord[] = [];
// "Unimported" = missing from the ledger. We also include threads
// already present in the backend (without a ledger row) so the ledger
// gets backfilled for old-FE-then-new-FE users -- otherwise the next
// launch would redo the diff for the same threads forever.
for (const thread of legacyThreads) {
if (isChatThreadDeleted(thread.id)) continue;
if (importedThreadIds.has(thread.id)) continue;
unimportedIds.push(thread.id);
unimportedThreads.push(thread);
}
if (unimportedIds.length === 0) {
markLegacyChatImportDone();
return;
}
// Two bulk reads instead of 2N per-thread round-trips.
const allLegacyMessages = await db.messages
.where("threadId")
.anyOf(unimportedIds)
.toArray()
.catch(() => [] as MessageRecord[]);
const legacyByThread = new Map<string, MessageRecord[]>();
for (const message of allLegacyMessages) {
const arr = legacyByThread.get(message.threadId);
if (arr) arr.push(message);
else legacyByThread.set(message.threadId, [message]);
}
const backendByThread = await batchListChatMessages(unimportedIds).catch(
() => new Map<string, MessageRecord[]>(),
);
const newlyImportedIds: string[] = [];
for (const thread of unimportedThreads) {
const backendThread = backendThreadsById.get(thread.id);
if (!backendThread) {
await saveChatThread(thread);
backendThreadsById.set(thread.id, thread);
} else {
backendThreadsById.set(
thread.id,
await backfillLegacyThreadFields(backendThread, thread),
);
}
const legacyMessages = legacyByThread.get(thread.id) ?? [];
if (legacyMessages.length === 0) {
newlyImportedIds.push(thread.id);
continue;
}
const backendMessages = backendByThread.get(thread.id) ?? [];
const merged = mergeMessages(backendMessages, legacyMessages);
if (merged.shouldSync) {
await syncChatMessages(thread.id, sortMessages(merged.messages), {
pruneMissing: false,
});
}
newlyImportedIds.push(thread.id);
}
if (newlyImportedIds.length === 0) {
markLegacyChatImportDone();
return;
}
let result: { supported: boolean };
try {
result = await recordChatImportLedger(newlyImportedIds);
} catch {
// Network error: leave the perf hint alone so the next launch
// retries. The import itself is idempotent via UPSERT, no
// duplicates.
return;
}
// Only flip the localStorage hint when the backend actually has the
// ledger. On older deployments (404/405/501) the hint would lie:
// "import done" while the ledger stays empty, defeating recovery
// when studio.db gets wiped later.
if (result.supported) {
markLegacyChatImportDone();
}
})();
try {
await legacyChatImportPromise;
} catch (error) {
legacyChatImportPromise = null;
throw error;
}
}
export async function getStoredChatThread(
threadId: string,
): Promise<ThreadRecord | undefined> {
if (isChatThreadDeleted(threadId)) return undefined;
const legacyThread = await db.threads.get(threadId);
let backendThread: ThreadRecord | null;
try {
backendThread = await getChatThread(threadId);
} catch (error) {
if (legacyThread && !isChatThreadDeleted(legacyThread.id)) {
return legacyThread;
}
throw error;
}
if (backendThread && !isChatThreadDeleted(backendThread.id)) {
return backfillLegacyThreadFields(backendThread, legacyThread);
}
if (!legacyThread || isChatThreadDeleted(legacyThread.id)) return undefined;
return importLegacyThread(legacyThread).catch(() => legacyThread);
}
export async function ensureStoredChatThread(
threadId: string,
fallback?: ThreadRecord,
): Promise<ThreadRecord | undefined> {
if (isChatThreadDeleted(threadId)) return undefined;
const legacyThread = fallback ?? (await db.threads.get(threadId));
let backendThread: ThreadRecord | null;
try {
backendThread = await getChatThread(threadId);
} catch (error) {
if (!legacyThread || isChatThreadDeleted(legacyThread.id)) {
throw error;
}
return legacyThread;
}
if (backendThread) {
return backfillLegacyThreadFields(backendThread, legacyThread);
}
if (!legacyThread || isChatThreadDeleted(legacyThread.id)) return undefined;
return importLegacyThread(legacyThread).catch(() => legacyThread);
}
export async function listStoredChatMessages(
threadId: string,
): Promise<MessageRecord[]> {
if (isChatThreadDeleted(threadId)) return [];
const legacyMessages = await db.messages
.where("threadId")
.equals(threadId)
.toArray();
const [backendThread, backendMessages] = await Promise.all([
getChatThread(threadId).catch(() => undefined),
listChatMessages(threadId).catch((error) => {
if (legacyMessages.length > 0) {
return undefined;
}
throw error;
}),
]);
if (backendMessages && (backendThread || backendMessages.length > 0)) {
const merged = mergeMessages(backendMessages, legacyMessages, {
includeLegacyOnly:
!isLegacyChatImportDone() ||
(backendMessages.length === 0 && legacyMessages.length > 0),
});
if (legacyMessages.length > 0 && merged.shouldSync) {
return syncChatMessages(threadId, merged.messages, {
pruneMissing: false,
}).catch(() => merged.messages);
}
return merged.messages;
}
if (
backendMessages &&
isLegacyChatImportDone() &&
legacyMessages.length === 0
) {
return [];
}
return legacyMessages.filter(
(message) => !isChatThreadDeleted(message.threadId),
);
}
export async function getStoredChatMessage(
threadId: string,
messageId: string,
): Promise<MessageRecord | undefined> {
if (isChatThreadDeleted(threadId)) return undefined;
const legacyMessage = await db.messages.get(messageId);
const matchingLegacyMessage =
legacyMessage?.threadId === threadId ? legacyMessage : undefined;
let backendMessage: MessageRecord | null;
try {
backendMessage = await getChatMessage(threadId, messageId);
} catch (error) {
if (matchingLegacyMessage) {
return matchingLegacyMessage;
}
throw error;
}
if (backendMessage) {
if (
matchingLegacyMessage &&
messageNeedsBackfill(backendMessage, matchingLegacyMessage)
) {
return mergeLegacyMessageFields(backendMessage, matchingLegacyMessage);
}
return backendMessage;
}
return matchingLegacyMessage;
}
export async function listStoredChatThreads(
args: ThreadListArgs = {},
): Promise<ThreadRecord[]> {
const legacyThreads = await listLegacyThreads(args);
let backendThreads = await listChatThreads(args).catch((error) => {
if (legacyThreads.length > 0) {
return undefined;
}
throw error;
});
if (backendThreads) {
await importLegacyChatsIfNeeded().catch(() => undefined);
backendThreads = await listChatThreads(args).catch(() => backendThreads);
}
const includeLegacyOnly =
!backendThreads ||
!isLegacyChatImportDone() ||
(backendThreads.length === 0 && legacyThreads.length > 0);
const byId = new Map<string, ThreadRecord>();
if (includeLegacyOnly) {
for (const thread of legacyThreads) byId.set(thread.id, thread);
}
for (const thread of backendThreads ?? []) {
if (!isChatThreadDeleted(thread.id)) byId.set(thread.id, thread);
}
return Array.from(byId.values())
.filter((thread) => matchesThreadListArgs(thread, args))
.sort((a, b) => b.createdAt - a.createdAt);
}
export async function listStoredChatThreadsWithMessages(
args: ThreadListArgs = {},
): Promise<ThreadRecord[]> {
const threads = await listStoredChatThreads(args);
if (threads.length === 0) return [];
// One batched HTTP call instead of N. Per-thread legacy Dexie
// fallback only fires when the batch result is empty.
const threadIds = threads.map((t) => t.id);
let backendByThread: Map<string, MessageRecord[]>;
try {
backendByThread = await batchListChatMessages(threadIds);
} catch {
backendByThread = new Map();
}
const entries = await Promise.all(
threads.map(async (thread) => {
const backendMessages = backendByThread.get(thread.id) ?? [];
if (backendMessages.length > 0) {
return { thread, hasContent: true };
}
const legacy = await listStoredChatMessages(thread.id).catch(() => null);
return { thread, hasContent: legacy === null || legacy.length > 0 };
}),
);
return entries.filter((e) => e.hasContent).map((e) => e.thread);
}
export async function saveStoredChatMessage(
message: MessageRecord,
): Promise<MessageRecord> {
if (isChatThreadDeleted(message.threadId)) {
throw new Error(`Thread ${message.threadId} was deleted`);
}
await ensureStoredChatThread(message.threadId);
return saveChatMessage(message);
}
export async function syncStoredChatMessages(
threadId: string,
messages: MessageRecord[],
options: { pruneMissing?: boolean } = {},
): Promise<MessageRecord[]> {
if (isChatThreadDeleted(threadId)) return [];
await ensureStoredChatThread(threadId);
return syncChatMessages(threadId, messages, options);
}
export async function saveStoredChatThread(
thread: ThreadRecord,
): Promise<ThreadRecord> {
if (isChatThreadDeleted(thread.id)) {
throw new Error(`Thread ${thread.id} was deleted`);
}
return saveChatThread(thread);
}
export async function updateStoredChatThread(
threadId: string,
patch: Partial<ThreadRecord>,
): Promise<ThreadRecord | undefined> {
const thread = await ensureStoredChatThread(threadId);
if (!thread) return undefined;
return updateChatThread(threadId, patch);
}
export async function deleteStoredChatThreads(
idsToDelete: string[],
): Promise<void> {
if (idsToDelete.length === 0) return;
await deleteChatThreads(idsToDelete);
await db
.transaction("rw", db.threads, db.messages, async () => {
await db.messages.where("threadId").anyOf(idsToDelete).delete();
await db.threads.bulkDelete(idsToDelete);
})
.catch(() => undefined);
markChatThreadsDeleted(idsToDelete);
}
export async function countStoredChats(): Promise<number> {
return (await listStoredChatThreads()).length;
}
export interface ClearStoredChatsResult {
backend: "cleared" | "failed" | "skipped";
legacy: "cleared" | "failed" | "skipped";
deletedThreadIds: string[];
failedThreadIds: string[];
}
export async function clearStoredChats(): Promise<ClearStoredChatsResult> {
// Clear both sides independently and report each outcome so the
// toast can distinguish full vs partial success.
const [backendThreadsResult, legacyThreads] = await Promise.all([
listChatThreads()
.then((threads) => ({ ok: true as const, threads }))
.catch(() => ({ ok: false as const, threads: [] as ThreadRecord[] })),
db.threads.toArray().catch(() => []),
]);
const backendInventoryLoaded = backendThreadsResult.ok;
const backendThreadIds = new Set(
backendThreadsResult.threads.map((thread) => thread.id),
);
const legacyThreadIds = new Set(legacyThreads.map((thread) => thread.id));
const allThreadIds = Array.from(
new Set([...backendThreadIds, ...legacyThreadIds]),
);
const result: ClearStoredChatsResult = {
backend: "skipped",
legacy: "skipped",
deletedThreadIds: [],
failedThreadIds: [],
};
try {
// Defer the history refresh until Dexie clear and tombstone state are
// finalized, so listeners never observe the composite clear mid-flight.
await clearBackendChats({ notify: false });
result.backend = "cleared";
} catch (error) {
result.backend = "failed";
console.error("clearStoredChats: backend clear failed", error);
}
try {
await db.transaction("rw", db.threads, db.messages, async () => {
await db.messages.clear();
await db.threads.clear();
});
result.legacy = "cleared";
} catch (error) {
result.legacy = "failed";
console.error("clearStoredChats: legacy Dexie clear failed", error);
}
result.deletedThreadIds = allThreadIds.filter((id) => {
const backendDeleted =
result.backend === "cleared" ||
(backendInventoryLoaded && !backendThreadIds.has(id));
const legacyDeleted =
!legacyThreadIds.has(id) || result.legacy === "cleared";
return backendDeleted && legacyDeleted;
});
const deleted = new Set(result.deletedThreadIds);
result.failedThreadIds = allThreadIds.filter((id) => !deleted.has(id));
markChatThreadsDeleted(result.deletedThreadIds);
notifyChatHistoryUpdated();
if (result.backend === "failed" && result.legacy === "failed") {
throw new Error("clearStoredChats: both backend and legacy clear failed");
}
return result;
}
export async function buildStoredChatExport(): Promise<ExportedChat> {
await importLegacyChatsIfNeeded().catch(() => undefined);
const [legacyThreads, legacyMessages] = await Promise.all([
db.threads.toArray(),
db.messages.toArray(),
]);
const hasLegacyData =
legacyThreads.some((thread) => !isChatThreadDeleted(thread.id)) ||
legacyMessages.some((message) => !isChatThreadDeleted(message.threadId));
const backend = await buildBackendChatExport().catch((error) => {
if (hasLegacyData) {
return null;
}
throw error;
});
const threadsById = new Map<string, unknown>();
const backendThreadIds = new Set<string>();
const messagesById = new Map<string, unknown>();
for (const thread of backend?.threads ?? []) {
if (isChatThreadDeleted(thread.id)) continue;
backendThreadIds.add(thread.id);
threadsById.set(thread.id, thread);
}
for (const message of backend?.messages ?? []) {
if (isChatThreadDeleted(message.threadId)) continue;
messagesById.set(message.id, message);
}
const includeLegacyOnly = backend === null || !isLegacyChatImportDone();
for (const thread of legacyThreads as ThreadRecord[]) {
if (
isChatThreadDeleted(thread.id) ||
backendThreadIds.has(thread.id) ||
!includeLegacyOnly
) {
continue;
}
threadsById.set(thread.id, thread);
}
for (const message of legacyMessages as MessageRecord[]) {
if (isChatThreadDeleted(message.threadId)) {
continue;
}
if (!includeLegacyOnly) continue;
if (!messagesById.has(message.id)) {
messagesById.set(message.id, message);
}
}
const threads = Array.from(threadsById.values());
const messages = Array.from(messagesById.values());
return {
exportedAt: new Date().toISOString(),
version: 1,
threadCount: threads.length,
threads,
messages,
};
}

View file

@ -0,0 +1,403 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import {
getChatSettings,
saveChatSettingsPatch,
type PersistedChatPreset,
type PersistedChatSettings,
type PersistedInferenceParams,
} from "../api/chat-settings-api";
import {
BUILTIN_PRESETS,
defaultInferenceParams,
getPresetOwnedConfigKey,
getUniquePresetName,
normalizeCustomPresets,
type ChatPresetSource,
type Preset,
} from "../presets/preset-policy";
import type { ReasoningEffort } from "../stores/chat-runtime-store";
const AUTO_TITLE_KEY = "unsloth_chat_auto_title";
const AUTO_HEAL_TOOL_CALLS_KEY = "unsloth_auto_heal_tool_calls";
const MAX_TOOL_CALLS_KEY = "unsloth_max_tool_calls_per_message";
const TOOL_CALL_TIMEOUT_KEY = "unsloth_tool_call_timeout";
const INFERENCE_PARAMS_KEY = "unsloth_chat_inference_params";
const CHAT_ACTIVE_PRESET_KEY = "unsloth_chat_active_preset";
const CHAT_ACTIVE_PRESET_SOURCE_KEY = "unsloth_chat_active_preset_source";
const REASONING_EFFORT_KEY = "unsloth_reasoning_effort";
const PRESERVE_THINKING_KEY = "unsloth_preserve_thinking";
const CHAT_PRESETS_KEY = "unsloth_chat_custom_presets";
const LEGACY_CHAT_SYSTEM_PROMPTS_KEY = "unsloth_chat_system_prompts";
const LEGACY_CHAT_SETTINGS_IMPORT_KEY =
"unsloth_chat_settings_imported_to_studio_db";
const NUMERIC_INFERENCE_FIELDS = [
"temperature",
"topP",
"topK",
"minP",
"repetitionPenalty",
"presencePenalty",
"maxSeqLength",
"maxTokens",
] as const satisfies readonly (keyof PersistedInferenceParams)[];
const CHAT_PRESET_SOURCES = new Set<string>([
"builtin-default",
"custom",
"modified",
]);
const REASONING_EFFORTS = new Set<string>([
"none",
"minimal",
"low",
"medium",
"high",
"max",
"xhigh",
]);
interface LegacySystemPromptTemplate {
name: string;
content: string;
}
function canUseStorage(): boolean {
return typeof window !== "undefined";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
function hasKeys(value: object): boolean {
return Object.keys(value).length > 0;
}
function getStorageItem(key: string): string | null {
if (!canUseStorage()) return null;
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
function isLegacySettingsImportDone(): boolean {
return getStorageItem(LEGACY_CHAT_SETTINGS_IMPORT_KEY) === "true";
}
function markLegacySettingsImportDone(): void {
if (!canUseStorage()) return;
try {
localStorage.setItem(LEGACY_CHAT_SETTINGS_IMPORT_KEY, "true");
} catch {
// ignore
}
}
function parseJson(value: string | null): unknown {
if (!value) return undefined;
try {
return JSON.parse(value) as unknown;
} catch {
return undefined;
}
}
function loadBool(key: string): boolean | undefined {
const raw = getStorageItem(key);
if (raw === "true") return true;
if (raw === "false") return false;
return undefined;
}
function loadInt(key: string, min: number): number | undefined {
const raw = getStorageItem(key);
if (raw == null || raw.trim() === "") return undefined;
const value = Number(raw);
return Number.isInteger(value) && value >= min ? value : undefined;
}
function sanitizeInferenceParams(
value: unknown,
): PersistedInferenceParams | undefined {
if (!isRecord(value)) return undefined;
const params: PersistedInferenceParams = {};
for (const field of NUMERIC_INFERENCE_FIELDS) {
const fieldValue = value[field];
if (typeof fieldValue === "number" && Number.isFinite(fieldValue)) {
params[field] = fieldValue;
}
}
if (typeof value.systemPrompt === "string") {
params.systemPrompt = value.systemPrompt;
}
if (typeof value.trustRemoteCode === "boolean") {
params.trustRemoteCode = value.trustRemoteCode;
}
return hasKeys(params) ? params : undefined;
}
function toFullPreset(preset: PersistedChatPreset): Preset {
return {
name: preset.name,
params: {
...defaultInferenceParams,
...preset.params,
checkpoint: defaultInferenceParams.checkpoint,
},
};
}
function sanitizeCustomPresets(
value: unknown,
): PersistedChatPreset[] | undefined {
if (!Array.isArray(value)) return undefined;
if (value.length === 0) return [];
const presets = value
.map((item): PersistedChatPreset | null => {
if (!isRecord(item) || typeof item.name !== "string") return null;
const name = item.name.trim();
if (!name) return null;
const params = sanitizeInferenceParams(item.params);
return { name, params: params ?? {} };
})
.filter((preset): preset is PersistedChatPreset => preset !== null);
if (presets.length === 0) return [];
return normalizeCustomPresets(presets.map(toFullPreset)).map(
(preset, index) => ({
name: preset.name,
params: presets[index]?.params ?? {},
}),
);
}
function sanitizePresetSource(value: unknown): ChatPresetSource | undefined {
return typeof value === "string" && CHAT_PRESET_SOURCES.has(value)
? (value as ChatPresetSource)
: undefined;
}
function sanitizeReasoningEffort(value: unknown): ReasoningEffort | undefined {
return typeof value === "string" && REASONING_EFFORTS.has(value)
? (value as ReasoningEffort)
: undefined;
}
function sanitizeBool(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
function sanitizeInt(value: unknown, min: number): number | undefined {
return typeof value === "number" && Number.isInteger(value) && value >= min
? value
: undefined;
}
function sanitizeChatSettings(value: unknown): PersistedChatSettings {
if (!isRecord(value)) return {};
const settings: PersistedChatSettings = {};
const inferenceParams = sanitizeInferenceParams(value.inferenceParams);
const customPresets = sanitizeCustomPresets(value.customPresets);
const activePresetSource = sanitizePresetSource(value.activePresetSource);
const reasoningEffort = sanitizeReasoningEffort(value.reasoningEffort);
const autoTitle = sanitizeBool(value.autoTitle);
const preserveThinking = sanitizeBool(value.preserveThinking);
const autoHealToolCalls = sanitizeBool(value.autoHealToolCalls);
const maxToolCallsPerMessage = sanitizeInt(value.maxToolCallsPerMessage, 1);
const toolCallTimeout = sanitizeInt(value.toolCallTimeout, 1);
if (inferenceParams) settings.inferenceParams = inferenceParams;
if (customPresets !== undefined) settings.customPresets = customPresets;
if (typeof value.activePreset === "string" && value.activePreset.trim()) {
settings.activePreset = value.activePreset.trim();
}
if (activePresetSource) settings.activePresetSource = activePresetSource;
if (autoTitle !== undefined) settings.autoTitle = autoTitle;
if (reasoningEffort) settings.reasoningEffort = reasoningEffort;
if (preserveThinking !== undefined)
settings.preserveThinking = preserveThinking;
if (autoHealToolCalls !== undefined) {
settings.autoHealToolCalls = autoHealToolCalls;
}
if (maxToolCallsPerMessage !== undefined) {
settings.maxToolCallsPerMessage = maxToolCallsPerMessage;
}
if (toolCallTimeout !== undefined) settings.toolCallTimeout = toolCallTimeout;
return settings;
}
function loadLegacySystemPromptPresets(
existingPresets: PersistedChatPreset[],
): PersistedChatPreset[] {
const parsed = parseJson(getStorageItem(LEGACY_CHAT_SYSTEM_PROMPTS_KEY));
if (!Array.isArray(parsed)) return [];
const usedNames = new Set([
...BUILTIN_PRESETS.map((preset) => preset.name),
...existingPresets.map((preset) => preset.name),
]);
const seenConfigKeys = new Set(
[...BUILTIN_PRESETS, ...existingPresets.map(toFullPreset)].map((preset) =>
getPresetOwnedConfigKey(preset.params),
),
);
return parsed
.filter((item): item is LegacySystemPromptTemplate => {
if (!isRecord(item)) return false;
return typeof item.name === "string" && typeof item.content === "string";
})
.map((template) => ({
template,
params: {
...defaultInferenceParams,
systemPrompt: template.content,
},
}))
.filter(({ params }) => {
const configKey = getPresetOwnedConfigKey(params);
if (seenConfigKeys.has(configKey)) return false;
seenConfigKeys.add(configKey);
return true;
})
.map(({ template, params }) => ({
name: getUniquePresetName(`${template.name} Prompt`, usedNames),
params: sanitizeInferenceParams(params) ?? {},
}));
}
export function isEmptyChatSettings(settings: PersistedChatSettings): boolean {
return (
(!settings.inferenceParams || !hasKeys(settings.inferenceParams)) &&
settings.customPresets === undefined &&
settings.activePreset === undefined &&
settings.activePresetSource === undefined &&
settings.autoTitle === undefined &&
settings.reasoningEffort === undefined &&
settings.preserveThinking === undefined &&
settings.autoHealToolCalls === undefined &&
settings.maxToolCallsPerMessage === undefined &&
settings.toolCallTimeout === undefined
);
}
export function loadLegacyChatSettings(): PersistedChatSettings {
const settings: PersistedChatSettings = {};
const rawCustomPresets = getStorageItem(CHAT_PRESETS_KEY);
const rawLegacyPromptPresets = getStorageItem(LEGACY_CHAT_SYSTEM_PROMPTS_KEY);
const hasLegacyPresetStorage =
rawCustomPresets !== null || rawLegacyPromptPresets !== null;
const inferenceParams = sanitizeInferenceParams(
parseJson(getStorageItem(INFERENCE_PARAMS_KEY)),
);
const customPresets = sanitizeCustomPresets(parseJson(rawCustomPresets));
const legacyPromptPresets = loadLegacySystemPromptPresets(
customPresets ?? [],
);
const activePreset = getStorageItem(CHAT_ACTIVE_PRESET_KEY);
const activePresetSource = sanitizePresetSource(
getStorageItem(CHAT_ACTIVE_PRESET_SOURCE_KEY),
);
const reasoningEffort = sanitizeReasoningEffort(
getStorageItem(REASONING_EFFORT_KEY),
);
const autoTitle = loadBool(AUTO_TITLE_KEY);
const preserveThinking = loadBool(PRESERVE_THINKING_KEY);
const autoHealToolCalls = loadBool(AUTO_HEAL_TOOL_CALLS_KEY);
const maxToolCallsPerMessage = loadInt(MAX_TOOL_CALLS_KEY, 1);
const toolCallTimeout = loadInt(TOOL_CALL_TIMEOUT_KEY, 1);
const allCustomPresets = sanitizeCustomPresets([
...(customPresets ?? []),
...legacyPromptPresets,
]);
if (inferenceParams) settings.inferenceParams = inferenceParams;
if (hasLegacyPresetStorage && allCustomPresets !== undefined) {
settings.customPresets = allCustomPresets;
}
if (activePreset?.trim()) settings.activePreset = activePreset.trim();
if (activePresetSource) settings.activePresetSource = activePresetSource;
if (autoTitle !== undefined) settings.autoTitle = autoTitle;
if (reasoningEffort) settings.reasoningEffort = reasoningEffort;
if (preserveThinking !== undefined)
settings.preserveThinking = preserveThinking;
if (autoHealToolCalls !== undefined) {
settings.autoHealToolCalls = autoHealToolCalls;
}
if (maxToolCallsPerMessage !== undefined) {
settings.maxToolCallsPerMessage = maxToolCallsPerMessage;
}
if (toolCallTimeout !== undefined) settings.toolCallTimeout = toolCallTimeout;
return settings;
}
export async function loadChatSettingsWithLegacyImport(): Promise<PersistedChatSettings> {
let dbSettings: PersistedChatSettings;
try {
dbSettings = sanitizeChatSettings(await getChatSettings());
} catch (error) {
const legacySettings = loadLegacyChatSettings();
if (isEmptyChatSettings(legacySettings)) {
throw error;
}
return legacySettings;
}
const legacySettings = loadLegacyChatSettings();
if (isLegacySettingsImportDone()) {
if (
!isEmptyChatSettings(dbSettings) ||
isEmptyChatSettings(legacySettings)
) {
return dbSettings;
}
try {
return sanitizeChatSettings(await saveChatSettingsPatch(legacySettings));
} catch {
return legacySettings;
}
}
if (isEmptyChatSettings(legacySettings)) {
markLegacySettingsImportDone();
return dbSettings;
}
const mergedSettings = {
...legacySettings,
...dbSettings,
inferenceParams: {
...legacySettings.inferenceParams,
...dbSettings.inferenceParams,
},
};
try {
const savedSettings = sanitizeChatSettings(
await saveChatSettingsPatch(mergedSettings),
);
markLegacySettingsImportDone();
return savedSettings;
} catch {
return mergedSettings;
}
}
export async function savePersistedChatSettingsPatch(
patch: PersistedChatSettings,
options: { keepalive?: boolean } = {},
): Promise<PersistedChatSettings> {
return sanitizeChatSettings(
await saveChatSettingsPatch(sanitizeChatSettings(patch), options),
);
}

View file

@ -1,12 +1,121 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
const deletedThreadIds = new Set<string>();
/**
* Tombstones mask deleted threads in the Dexie read fallback. Each
* carries a `deletedAt` timestamp so old entries can be GC'd, keeping
* localStorage bounded. Reads accept both the legacy plain-string
* format and the new {id, deletedAt} tuple form.
*/
interface Tombstone {
id: string;
deletedAt: number;
}
const TOMBSTONES_KEY = "unsloth_chat_deleted_thread_ids";
const TOMBSTONE_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000; // 90 days
const TOMBSTONE_MAX_COUNT = 5000;
const deletedThreads = new Map<string, Tombstone>();
function canUseStorage(): boolean {
return typeof window !== "undefined";
}
function nowMs(): number {
return Date.now();
}
function isTombstone(value: unknown): value is Tombstone {
return (
typeof value === "object" &&
value !== null &&
typeof (value as Tombstone).id === "string" &&
typeof (value as Tombstone).deletedAt === "number"
);
}
function loadTombstones(): Tombstone[] {
if (!canUseStorage()) return [];
try {
const raw = JSON.parse(localStorage.getItem(TOMBSTONES_KEY) ?? "[]");
if (!Array.isArray(raw)) return [];
const now = nowMs();
const out: Tombstone[] = [];
for (const item of raw) {
if (typeof item === "string") {
// Legacy plain-string format from pre-B6 installs.
out.push({ id: item, deletedAt: now });
} else if (isTombstone(item)) {
out.push(item);
}
}
return out;
} catch {
return [];
}
}
function gc(): void {
const cutoff = nowMs() - TOMBSTONE_MAX_AGE_MS;
for (const [id, t] of deletedThreads) {
if (t.deletedAt < cutoff) deletedThreads.delete(id);
}
// Cap absolute size: drop oldest if we somehow exceed the limit
// (e.g. a script clearing thousands of threads at once).
if (deletedThreads.size > TOMBSTONE_MAX_COUNT) {
const sorted = Array.from(deletedThreads.entries()).sort(
(a, b) => a[1].deletedAt - b[1].deletedAt,
);
const drop = sorted.slice(0, deletedThreads.size - TOMBSTONE_MAX_COUNT);
for (const [id] of drop) deletedThreads.delete(id);
}
}
function persist(): void {
if (!canUseStorage()) return;
try {
const arr = Array.from(deletedThreads.values());
localStorage.setItem(TOMBSTONES_KEY, JSON.stringify(arr));
} catch {
// ignore quota / serialization failures
}
}
for (const t of loadTombstones()) {
deletedThreads.set(t.id, t);
}
gc();
export function markChatThreadDeleted(threadId: string): void {
deletedThreadIds.add(threadId);
deletedThreads.set(threadId, { id: threadId, deletedAt: nowMs() });
gc();
persist();
}
export function markChatThreadsDeleted(threadIds: Iterable<string>): void {
const now = nowMs();
for (const id of threadIds) {
deletedThreads.set(id, { id, deletedAt: now });
}
gc();
persist();
}
export function isChatThreadDeleted(threadId: string): boolean {
return deletedThreadIds.has(threadId);
return deletedThreads.has(threadId);
}
/** Rollback support: drop tombstones when a backend delete fails. */
export function removeChatThreadTombstones(threadIds: Iterable<string>): void {
let changed = false;
for (const id of threadIds) {
if (deletedThreads.delete(id)) changed = true;
}
if (changed) persist();
}
export function __resetChatThreadTombstonesForTests(): void {
deletedThreads.clear();
}

View file

@ -1,15 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { db } from "../db";
import { clearStoredChats, countStoredChats } from "./chat-history-storage";
export async function countAllChats(): Promise<number> {
return db.threads.count();
}
export const countAllChats = countStoredChats;
export async function clearAllChats(): Promise<void> {
await db.transaction("rw", db.threads, db.messages, async () => {
await db.messages.clear();
await db.threads.clear();
});
}
export const clearAllChats = clearStoredChats;

View file

@ -1,11 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import type {
CompleteAttachment,
ExportedMessageRepository,
ThreadMessage,
} from "@assistant-ui/react";
/**
* assistant-ui does not expose a public `deleteMessage` on `ThreadRuntime` / `MessageRuntime`
* in our version, but it already implements branch-safe deletion inside `MessageRepository`.
@ -19,10 +14,20 @@ import type {
* surface area.
*/
import { MessageRepository } from "@assistant-ui/core/internal";
import { db } from "../db";
import type {
CompleteAttachment,
ExportedMessageRepository,
ThreadMessage,
} from "@assistant-ui/react";
import type { MessageRecord } from "../types";
import {
ensureStoredChatThread,
syncStoredChatMessages,
} from "./chat-history-storage";
function cloneContent(content: ThreadMessage["content"]): ThreadMessage["content"] {
function cloneContent(
content: ThreadMessage["content"],
): ThreadMessage["content"] {
if (typeof content === "string") {
return content;
}
@ -38,7 +43,7 @@ function cloneAttachments(
return JSON.parse(JSON.stringify(attachments));
}
function exportedItemToRecord(
export function exportedItemToRecord(
threadId: string,
parentId: string | null,
message: ThreadMessage,
@ -64,7 +69,10 @@ function exportedItemToRecord(
threadId,
parentId: parentId ?? null,
role: "assistant",
content: content as Extract<ThreadMessage, { role: "assistant" }>["content"],
content: content as Extract<
ThreadMessage,
{ role: "assistant" }
>["content"],
...(Object.keys(custom).length > 0 && { metadata: custom }),
createdAt: message.createdAt?.getTime?.() ?? Date.now(),
};
@ -73,29 +81,19 @@ function exportedItemToRecord(
/**
* Persist exported messages, pruning only for explicit delete flows.
*/
export async function syncExportedRepositoryToDexie(
export async function syncExportedRepositoryToBackend(
remoteId: string,
exp: ExportedMessageRepository,
options: { pruneMissing?: boolean } = {},
): Promise<void> {
await db.transaction("rw", db.messages, async () => {
if (options.pruneMissing) {
const keepIds = new Set(exp.messages.map((x) => x.message.id));
const existingIds = await db.messages
.where("threadId")
.equals(remoteId)
.primaryKeys();
const idsToDelete = existingIds.filter((id) => !keepIds.has(String(id)));
if (idsToDelete.length > 0) {
await db.messages.bulkDelete(idsToDelete);
}
}
await db.messages.bulkPut(
exp.messages.map(({ message, parentId }) =>
exportedItemToRecord(remoteId, parentId, message),
),
);
});
await ensureStoredChatThread(remoteId);
await syncStoredChatMessages(
remoteId,
exp.messages.map(({ message, parentId }) =>
exportedItemToRecord(remoteId, parentId, message),
),
{ pruneMissing: options.pruneMissing },
);
}
type ThreadImportExport = {
@ -104,7 +102,7 @@ type ThreadImportExport = {
};
/**
* Remove a message from the thread and mirror the result to IndexedDB.
* Remove a message from the thread and mirror the result to backend storage.
*/
export async function deleteThreadMessage(args: {
thread: ThreadImportExport;
@ -118,7 +116,9 @@ export async function deleteThreadMessage(args: {
repo.deleteMessage(messageId);
const next = repo.export();
if (remoteId) {
await syncExportedRepositoryToDexie(remoteId, next, { pruneMissing: true });
await syncExportedRepositoryToBackend(remoteId, next, {
pruneMissing: true,
});
}
thread.import(next);
}

View file

@ -1,29 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { db } from "../db";
import { buildStoredChatExport } from "./chat-history-storage";
interface ExportedChat {
exportedAt: string;
version: 1;
threadCount: number;
threads: unknown[];
messages: unknown[];
}
export async function buildChatExport(): Promise<ExportedChat> {
const [threads, messages] = await Promise.all([
db.threads.toArray(),
db.messages.toArray(),
]);
return {
exportedAt: new Date().toISOString(),
version: 1,
threadCount: threads.length,
threads,
messages,
};
}
export const buildChatExport = buildStoredChatExport;
export async function downloadChatExport(): Promise<void> {
const data = await buildChatExport();

View file

@ -2,6 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import { toast } from "@/lib/toast";
import {
Dialog,
DialogContent,
@ -13,8 +14,8 @@ import {
import {
clearAllChats,
countAllChats,
} from "@/features/chat/utils/clear-all-chats";
import { downloadChatExport } from "@/features/chat/utils/export-chat-history";
downloadChatExport,
} from "@/features/chat";
import { Delete02Icon, Download02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useEffect, useState } from "react";
@ -43,9 +44,44 @@ export function ChatTab() {
const handleClear = async () => {
setClearing(true);
try {
await clearAllChats();
setCount(0);
const result = await clearAllChats();
const clearedCount = result.deletedThreadIds.length;
const hasFailedStore =
result.backend === "failed" || result.legacy === "failed";
if (!hasFailedStore && result.failedThreadIds.length === 0) {
setCount(0);
setConfirmOpen(false);
toast.success(
clearedCount === 0
? "Cleared all chats"
: `Cleared ${clearedCount} chat${clearedCount === 1 ? "" : "s"}`,
);
return;
}
const fallbackRemaining =
result.failedThreadIds.length > 0
? result.failedThreadIds.length
: (count ?? 0);
const remaining = await countAllChats().catch(() => fallbackRemaining);
setCount(remaining);
setConfirmOpen(false);
toast.warning("Some chats could not be cleared", {
description:
result.failedThreadIds.length > 0
? `${clearedCount} chat${clearedCount === 1 ? "" : "s"} cleared; ${
result.failedThreadIds.length
} chat${result.failedThreadIds.length === 1 ? "" : "s"} remain. Please retry.`
: `A storage clear failed; ${remaining} chat${
remaining === 1 ? "" : "s"
} may remain. Please retry.`,
});
} catch (error) {
const remaining = await countAllChats().catch(() => count);
setCount(remaining);
toast.error("Failed to clear chats", {
description: error instanceof Error ? error.message : undefined,
});
} finally {
setClearing(false);
}
@ -107,7 +143,8 @@ export function ChatTab() {
Clear {count ?? 0} chat{count === 1 ? "" : "s"}?
</DialogTitle>
<DialogDescription>
This permanently deletes every chat and message stored on this device. This cannot be undone.
This permanently deletes every chat and message stored on this
device. This cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
@ -119,7 +156,9 @@ export function ChatTab() {
disabled={clearing}
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
>
{clearing ? "Clearing…" : `Clear ${count ?? 0} chat${count === 1 ? "" : "s"}`}
{clearing
? "Clearing…"
: `Clear ${count ?? 0} chat${count === 1 ? "" : "s"}`}
</Button>
</DialogFooter>
</DialogContent>

View file

@ -199,7 +199,7 @@ export function GeneralTab() {
<SettingsRow
destructive
label="Reset all local preferences"
description="Clears theme, tokens, sidebar state, and presets. Chats and API access are not affected."
description="Clears local-only preferences. Chats, API access, and DB-backed chat settings are not affected."
>
<Button
variant="outline"
@ -217,8 +217,8 @@ export function GeneralTab() {
<DialogHeader>
<DialogTitle>Reset all local preferences?</DialogTitle>
<DialogDescription>
This clears your theme, tokens, and stored settings, then reloads
Studio. Chats and API access are not affected.
This clears local-only preferences, then reloads Studio. Chats,
API access, and DB-backed chat settings are not affected.
</DialogDescription>
</DialogHeader>
<DialogFooter>