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:
parent
a2d2b7866f
commit
61ed4cac51
29 changed files with 4674 additions and 1133 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
397
studio/backend/routes/chat_history.py
Normal file
397
studio/backend/routes/chat_history.py
Normal 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],
|
||||
)
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
109
studio/backend/tests/test_chat_history_routes.py
Normal file
109
studio/backend/tests/test_chat_history_routes.py
Normal 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)],
|
||||
)
|
||||
308
studio/backend/tests/test_chat_history_storage.py
Normal file
308
studio/backend/tests/test_chat_history_storage.py
Normal 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() == []
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
90
studio/frontend/src/features/chat/api/chat-settings-api.ts
Normal file
90
studio/frontend/src/features/chat/api/chat-settings-api.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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">;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: () =>
|
||||
|
|
|
|||
773
studio/frontend/src/features/chat/utils/chat-history-storage.ts
Normal file
773
studio/frontend/src/features/chat/utils/chat-history-storage.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
403
studio/frontend/src/features/chat/utils/chat-settings-storage.ts
Normal file
403
studio/frontend/src/features/chat/utils/chat-settings-storage.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue