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