ui: Stores split refactor (#27240)
* ui: Extract server stream lifecycle from chatStore into ChatStreamManager
Discovery, attach/replay, resume retry and the remote-running snapshot
formed a cohesive cluster inside chatStore. It now lives in
chat-streams.svelte.ts as ChatStreamManager, owned by chatStore, which
keeps the public entry points as delegates so components are
unchanged. chatStore: 2877 -> 2418 lines.
* ui: Extract user interaction gates from agenticStore into AgenticGates
Tool permission requests, turn-limit continue prompts and queued
steering messages are the state the loop waits on between turns. They
had no coupling to session state, so they now live in
agentic-gates.svelte.ts; agenticStore keeps delegates so components
are unchanged. agenticStore: 1196 -> 1073 lines.
* ui: Compose MCP resources under mcpStore.resources
Resource state was a second import scope next to mcpStore. Consumers
now go through mcpStore.resources, so the MCP surface is one store;
mcp-resources.svelte.ts stays a separate file owned by mcpStore.
* ui: Reorganize stores into domain namespaces
* fix: Update stale doc comments
* ui: Consolidate conv running-state into a chat activity ledger
Running-state was split across chatStore.chatLoadingStates (local
pipes), ChatStreamManager.remoteRunningConvs (backend sessions) and
attachingConvs (attach lifecycle), unioned by hand in
getAllLoadingChats and cross-cleaned by setChatLoading calling
streams.clearRemoteRunning - the 'spinner ghosts until tab toggle'
workaround.
chatActivityStore now owns both sets with one transition per event:
markLocal / localEnded (local pipe end also drops the stale remote
hint, no cross-owner call) / applyRemoteSnapshot (diffed). The
sidebar reads chatStore.activity.loadingConvs through the unchanged
getAllLoadingChats entry point.
Consequences:
- isStreamingActive and its five manual writers are gone; isStreaming()
now reports whether the active conversation has a live streaming
pipe, which is what all four consumers (assistant row, stop action,
context gauge, chat screen) actually check
- isLoading/isReasoning become derived from the per-conv maps plus
the active conversation, dropping the manual resync in
syncLoadingStateForChat and clearUIState
- attachingConvs and the last-attach coordination disappear from
ChatStreamManager
- getAllStreamingChats (no consumers) is removed
* ui: Give store collaborators narrow host interfaces
Collaborators took 'host: typeof <store>', i.e. the store's entire
public surface, which is how chatStore's streamChatCompletion,
createAssistantMessage, getApiOptions and setStreamingActive got
widened to public. Replace with per-collaborator interfaces carrying
only the members each one drives:
- ChatStreamHost (chat/streams) - activity, processing, streaming
states, abort controller, loading/streaming setters
- ChatFlowsHost (chat/flows) - streaming core, message creation,
per-conv state setters
- McpHealthHost (mcp/health) - connection registry + reconnection
- ModelPropsHost / ModelStatusHost (models) - model rows, feed
updates; the managers write modalities/status back onto the host's
rows, so those members stay writable
- ConversationsPreferencesHost (conversations) - the active row and
the conversation list
The store classes now declare 'implements <Host>' so the contract is
visible at the class level, and the 'import type { <store> }' back
references in the collaborators disappear entirely - the host
contract is local to each collaborator file, and collaborators can
no longer reach around their slice. Members stay public (structural
typing), but the collaborator side is now compiler-enforced.
* test: Chat Activity store test
* refactor: Cleanup
* chore: Remove legacy architecture docs
* ui: Memoize findMessageIndex for the streaming hot path
Streaming looks up the same message index on every chunk, a linear
scan of activeMessages each time. Cache the last lookup and reuse it
after validating the id still sits at the same position (O(1)); any
structural change to the array fails validation and falls back to a
full scan.
* ui: Throttle per-chunk stream state writes to localStorage
saveStreamState ran JSON.stringify + a synchronous localStorage.setItem
on every decoded chunk of the stream. The read loop now goes through a
new saveStreamStateThrottled (one write per conversation per 500ms,
latest value held pending); the public saveStreamState keeps its
immediate-write contract for stream start and pre-fetch, and also
resets the throttle window.
A pending offset is force-flushed at resume boundaries (resumeStream
reads the offset back from localStorage), on visibilitychange->hidden
and on pagehide, so a reload always finds a usable offset. The resume
offset only needs to be roughly current since the server retransmits
from a line boundary and the client discards its partial line.
Adds unit tests for the throttled/flush/clear interplay.
* ui: Compute context gauge timing stats in one pass
currentRead/Fresh/Cache/Output were separate deriveds, each running a
full reverse scan of activeMessages for the last assistant timings,
and cumulative ran its own forward scan plus an agentic filter - 4-5
O(n) passes per chunk while streaming. Replace with a single
summarizeAssistantTimings() pass (last assistant timings, last
agentic llm totals and the cumulative sums) feeding a shared derived
snapshot. Semantics unchanged, including the live-stats overrides and
the agentic llm-totals branch.
* agentic : clear session state when a conversation is deleted
Every conversation that ran an agentic flow left an AgenticSession in the
store forever; clearSession was never called. conversationsStore now
notifies deletion listeners and agenticStore drops the matching sessions,
avoiding a circular import back into conversationsStore.
* chat : extract ChatService.normalizeMessagesForApi
The DB->API message normalization (convert + drop empty system messages)
was duplicated in sendMessage, preEncode and the agentic flow. Extract it
into one shared method and call it from all three.
* sse : share record splitting and data extraction
splitSseRecords and extractSseDataPayload centralize the record-boundary
splitting and data: line extraction used by parseSseJsonStream and the
models status feed. chat.service keeps its own line-based parser for
resume support.
* api : delegate apiFetchWithParams to apiFetch
apiFetchWithParams duplicated apiFetch's headers/fetch/error handling
body-for-body; it only differs in URL construction. Build the URL and
delegate.
* chat flows : dedupe title, timings and cleanup handling
- conversationsStore.applyTitleFromContent centralizes the title-from-first-
message logic duplicated in 5 places
- ChatProcessingStore.applyStreamTimings centralizes the onTimings handler
shared by the chat and continue flows
- host.cleanupStreaming centralizes the loading/streaming/processing reset
repeated across the continue flow's exit paths
* conversations : centralize conversation update mirroring
rename, pin, mcp override, reasoning effort and cwd all repeated the same
write-DB-then-mirror-into-list-and-active dance. A single
applyConversationUpdate(id, updates) on the host collapses all five and
removes the forgot-to-mirror-one-field bug class. Drops the redundant
array reassignment in setCwd (deep field assignment is reactive).
* mcp : dedupe tool execution, server parsing and tool indexing
- executeTool delegates to executeToolByName (only diff was argument parsing)
- drop the private #parseServerSettings copy; use parseMcpServerSettings
- cache getServers() keyed on the raw config value (hot path)
- indexServerTools() unifies the three identical toolsIndex rebuild loops
Assisted-by: Claude
* mcp : share cursor pagination and tool indexing
- MCPService.paginate() collapses the identical do-while loops in
listAllResources and listAllResourceTemplates
- promoteHealthCheckToConnection now uses indexServerTools like the other
connect paths
Assisted-by: Claude
* database : share message parent-child bookkeeping
- addChildToParent() dedups the append-to-children update in createMessageBranch
and createSystemMessage
- removeChildFromParent() dedups the remove-from-children cleanup in deleteMessage
and deleteMessageCascading
- bulkAdd the cloned messages when forking a conversation instead of one add
per message
Assisted-by: Claude
* chore: Lint/format
* fix: `pagehide` event from `window`
* refactor: Api Fetch util
* docs : rewrite architecture sections in README
Update the high-level diagram, routes, hooks, stores, services and data
flow tables to match the current UI structure (mcp/settings/search
routes, agentic/tools/mcp stores, MCPService/ToolsService/SandboxService,
/tools API). Fix stale architectural patterns for per-conversation state
and modality validation.
* chore : add ESLint rule for blank lines between accessors
Enforce a blank line between consecutive class accessors. The core
padding-line-between-statements rule does not cover class members, so a
local rule is needed.
* refactor : reorder store members and unify naming
Order store class members as public fields, private fields, constructor,
getters, public methods, then private methods. Normalize private naming
to the `private` keyword (drop `#` and the `_` prefix where there is no
matching public getter). Rename conversationsStore.init() to
initialize() to match the other stores.
* refactor : prefix lookup methods with get in agentic and chat stores
Unify bare-name lookup methods with the get* prefix used across the
other stores (mcp, models, tools, settings). Renames currentTurn,
totalToolCalls, lastError, streamingToolCall, executingToolCallId,
pendingPermissionRequest, pendingContinueRequest,
pendingSteeringMessageContent, pendingSteeringMessageExtras in the
agentic store and pendingMessageContent, pendingMessageExtras in the
chat store. Updates the two consuming components and a doc comment.
* refactor: Clean up comments in stores' and services' code
* chore : add ESLint rule for class member ordering
Enforce structural order (public fields -> private fields -> constructor ->
getters -> setters -> public methods -> private methods) with alphabetical
sorting within each group via perfectionist/sort-classes. Dependency
detection keeps Svelte $derived fields in a valid dependency order instead
of alphabetizing them, since Svelte rejects forward references.
Assisted-by: Claude
* refactor : reorder class members to match new ESLint rule
Apply the sort-classes rule across stores, services, hooks and utils.
Pure reordering - verified no logic changes by comparing sorted line
multisets before/after. All tests and svelte-check pass.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -16,187 +16,6 @@ import {
|
||||
import { strFromU8, strToU8, unzipSync, zipSync } from 'fflate';
|
||||
|
||||
export class ConversationTransferService {
|
||||
/**
|
||||
*
|
||||
*
|
||||
* JSONL Session Format
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Serializes a session (a conversation with its messages) as JSONL.
|
||||
* The first line is the session header (a `SessionRecordType.SESSION` record
|
||||
* carrying the conversation properties); each subsequent line is a single message.
|
||||
* @param data - The exported conversation payload
|
||||
* @returns The JSONL string (one record per line)
|
||||
*/
|
||||
static serializeSessionToJsonl(data: ExportedConversation): string {
|
||||
const { conv, messages } = data;
|
||||
const sessionLine = JSON.stringify({
|
||||
harness: EXPORT_CONV.HARNESS,
|
||||
type: SessionRecordType.SESSION,
|
||||
...conv
|
||||
});
|
||||
const messageLines = messages.map((message: DatabaseMessage) => {
|
||||
// `toolCalls` is stored as a JSON string; drop it when empty, otherwise parse it.
|
||||
const { toolCalls, ...rest } = message;
|
||||
const normalized = toolCalls ? { ...rest, toolCalls: JSON.parse(toolCalls) } : rest;
|
||||
|
||||
return JSON.stringify({ message: normalized, type: SessionRecordType.MESSAGE });
|
||||
});
|
||||
|
||||
return [sessionLine, ...messageLines].join(NEWLINE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the JSONL session format produced by {@link serializeSessionToJsonl}.
|
||||
* A `SessionRecordType.SESSION` line starts a new session; following
|
||||
* `SessionRecordType.MESSAGE` lines are appended to it. Supports multiple
|
||||
* sessions in a single file.
|
||||
* @param text - The JSONL file contents
|
||||
* @returns The parsed conversations with their messages
|
||||
*/
|
||||
static parseSessionsJsonl(text: string): ExportedConversation[] {
|
||||
const sessions: ExportedConversation[] = [];
|
||||
|
||||
let current: ExportedConversation | null = null;
|
||||
|
||||
for (const line of text.split(NEWLINE)) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (!trimmed) continue;
|
||||
|
||||
const record = JSON.parse(trimmed);
|
||||
|
||||
if (record.type === SessionRecordType.SESSION) {
|
||||
// Drop the discriminator and harness marker; the rest is the conversation.
|
||||
const conv = { ...record };
|
||||
|
||||
delete conv.type;
|
||||
delete conv.harness;
|
||||
current = { conv: conv as DatabaseConversation, messages: [] };
|
||||
sessions.push(current);
|
||||
} else if (record.type === SessionRecordType.MESSAGE) {
|
||||
if (!current) {
|
||||
throw new Error('Invalid JSONL: message record before any session record');
|
||||
}
|
||||
|
||||
const message = record.message as DatabaseMessage;
|
||||
|
||||
// `toolCalls` is parsed to an array on export; the DB stores it as a string.
|
||||
if (message.toolCalls !== undefined && typeof message.toolCalls !== 'string') {
|
||||
message.toolCalls = JSON.stringify(message.toolCalls);
|
||||
}
|
||||
|
||||
current.messages.push(message);
|
||||
}
|
||||
// Ignore unknown record types for forward compatibility.
|
||||
}
|
||||
|
||||
return sessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether the text is the JSONL session format, whose first non-empty
|
||||
* line is a `SessionRecordType.SESSION` record. A legacy JSON export starts
|
||||
* with an array or an object that has no such discriminator.
|
||||
* @param text - The file contents
|
||||
*/
|
||||
private static isSessionsJsonl(text: string): boolean {
|
||||
const trimmed = text.trimStart();
|
||||
const lineEnd = trimmed.indexOf(NEWLINE);
|
||||
const firstLine = lineEnd === -1 ? trimmed : trimmed.slice(0, lineEnd);
|
||||
|
||||
try {
|
||||
return JSON.parse(firstLine).type === SessionRecordType.SESSION;
|
||||
} catch {
|
||||
// Not a standalone JSON record, so not the JSONL format.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an import file into conversations, accepting the current JSONL and
|
||||
* ZIP formats as well as the legacy JSON format. The format comes from the
|
||||
* contents, so an import works whatever the file is named.
|
||||
* @param file - The user-selected file
|
||||
* @returns The parsed conversations with their messages
|
||||
*/
|
||||
static async parseImportFile(file: File): Promise<ExportedConversation[]> {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
|
||||
if (ZIP_MAGIC.every((byte, index) => bytes[index] === byte)) {
|
||||
const entries = unzipSync(bytes);
|
||||
const sessions: ExportedConversation[] = [];
|
||||
|
||||
for (const [entryName, entryBytes] of Object.entries(entries)) {
|
||||
if (!entryName.toLowerCase().endsWith(FileExtensionText.JSONL)) continue;
|
||||
|
||||
sessions.push(...ConversationTransferService.parseSessionsJsonl(strFromU8(entryBytes)));
|
||||
}
|
||||
|
||||
return sessions;
|
||||
}
|
||||
|
||||
const text = strFromU8(bytes);
|
||||
|
||||
if (ConversationTransferService.isSessionsJsonl(text)) {
|
||||
return ConversationTransferService.parseSessionsJsonl(text);
|
||||
}
|
||||
|
||||
// Legacy JSON format: an array of conversations or a single conversation object.
|
||||
const parsed = JSON.parse(text);
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
if (parsed && typeof parsed === 'object' && 'conv' in parsed && 'messages' in parsed) {
|
||||
return [parsed];
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Invalid file format: expected array of conversations or single conversation object'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Downloads
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generates a sanitized filename for a conversation export
|
||||
* @param conversation - The conversation metadata
|
||||
* @param msgs - Optional array of messages belonging to the conversation
|
||||
* @returns The generated filename string
|
||||
*/
|
||||
static generateConversationFilename(
|
||||
conversation: { id?: string; name?: string },
|
||||
msgs?: DatabaseMessage[]
|
||||
): string {
|
||||
const conversationName = (conversation.name ?? '').trim().toLowerCase();
|
||||
const sanitizedName = conversationName
|
||||
.replace(EXPORT_CONV.NON_ALPHANUMERIC_REGEX, EXPORT_CONV.NONALNUM_REPLACEMENT)
|
||||
.replace(EXPORT_CONV.MULTIPLE_UNDERSCORE_REGEX, '_')
|
||||
.substring(0, EXPORT_CONV.NAME_SUFFIX_MAX_LENGTH);
|
||||
// If we have messages, use the timestamp of the newest message
|
||||
const referenceDate = msgs?.length
|
||||
? new Date(Math.max(...msgs.map((m) => m.timestamp)))
|
||||
: new Date();
|
||||
const iso = referenceDate.toISOString().slice(0, EXPORT_CONV.ISO_TIMESTAMP_SLICE);
|
||||
const formattedDate = iso
|
||||
.replace(EXPORT_CONV.ISO_DATE_TIME_SEPARATOR, EXPORT_CONV.ISO_DATE_TIME_SEPARATOR_REPLACEMENT)
|
||||
.replaceAll(EXPORT_CONV.ISO_TIME_SEPARATOR, EXPORT_CONV.ISO_TIME_SEPARATOR_REPLACEMENT);
|
||||
const trimmedConvId = conversation.id?.slice(0, EXPORT_CONV.ID_TRIM_LENGTH) ?? '';
|
||||
|
||||
return `${formattedDate}_conv_${trimmedConvId}_${sanitizedName}${FileExtensionText.JSONL}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a browser download of the provided exported conversation data
|
||||
* @param data - The exported conversation payload (a single conversation with its messages)
|
||||
@@ -262,6 +81,171 @@ export class ConversationTransferService {
|
||||
ConversationTransferService.triggerDownload(blob, archiveName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a sanitized filename for a conversation export
|
||||
* @param conversation - The conversation metadata
|
||||
* @param msgs - Optional array of messages belonging to the conversation
|
||||
* @returns The generated filename string
|
||||
*/
|
||||
static generateConversationFilename(
|
||||
conversation: { id?: string; name?: string },
|
||||
msgs?: DatabaseMessage[]
|
||||
): string {
|
||||
const conversationName = (conversation.name ?? '').trim().toLowerCase();
|
||||
const sanitizedName = conversationName
|
||||
.replace(EXPORT_CONV.NON_ALPHANUMERIC_REGEX, EXPORT_CONV.NONALNUM_REPLACEMENT)
|
||||
.replace(EXPORT_CONV.MULTIPLE_UNDERSCORE_REGEX, '_')
|
||||
.substring(0, EXPORT_CONV.NAME_SUFFIX_MAX_LENGTH);
|
||||
// If we have messages, use the timestamp of the newest message
|
||||
const referenceDate = msgs?.length
|
||||
? new Date(Math.max(...msgs.map((m) => m.timestamp)))
|
||||
: new Date();
|
||||
const iso = referenceDate.toISOString().slice(0, EXPORT_CONV.ISO_TIMESTAMP_SLICE);
|
||||
const formattedDate = iso
|
||||
.replace(EXPORT_CONV.ISO_DATE_TIME_SEPARATOR, EXPORT_CONV.ISO_DATE_TIME_SEPARATOR_REPLACEMENT)
|
||||
.replaceAll(EXPORT_CONV.ISO_TIME_SEPARATOR, EXPORT_CONV.ISO_TIME_SEPARATOR_REPLACEMENT);
|
||||
const trimmedConvId = conversation.id?.slice(0, EXPORT_CONV.ID_TRIM_LENGTH) ?? '';
|
||||
|
||||
return `${formattedDate}_conv_${trimmedConvId}_${sanitizedName}${FileExtensionText.JSONL}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an import file into conversations, accepting the current JSONL and
|
||||
* ZIP formats as well as the legacy JSON format. The format comes from the
|
||||
* contents, so an import works whatever the file is named.
|
||||
* @param file - The user-selected file
|
||||
* @returns The parsed conversations with their messages
|
||||
*/
|
||||
static async parseImportFile(file: File): Promise<ExportedConversation[]> {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
|
||||
if (ZIP_MAGIC.every((byte, index) => bytes[index] === byte)) {
|
||||
const entries = unzipSync(bytes);
|
||||
const sessions: ExportedConversation[] = [];
|
||||
|
||||
for (const [entryName, entryBytes] of Object.entries(entries)) {
|
||||
if (!entryName.toLowerCase().endsWith(FileExtensionText.JSONL)) continue;
|
||||
|
||||
sessions.push(...ConversationTransferService.parseSessionsJsonl(strFromU8(entryBytes)));
|
||||
}
|
||||
|
||||
return sessions;
|
||||
}
|
||||
|
||||
const text = strFromU8(bytes);
|
||||
|
||||
if (ConversationTransferService.isSessionsJsonl(text)) {
|
||||
return ConversationTransferService.parseSessionsJsonl(text);
|
||||
}
|
||||
|
||||
// Legacy JSON format: an array of conversations or a single conversation object.
|
||||
const parsed = JSON.parse(text);
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
if (parsed && typeof parsed === 'object' && 'conv' in parsed && 'messages' in parsed) {
|
||||
return [parsed];
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Invalid file format: expected array of conversations or single conversation object'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the JSONL session format produced by {@link serializeSessionToJsonl}.
|
||||
* A `SessionRecordType.SESSION` line starts a new session; following
|
||||
* `SessionRecordType.MESSAGE` lines are appended to it. Supports multiple
|
||||
* sessions in a single file.
|
||||
* @param text - The JSONL file contents
|
||||
* @returns The parsed conversations with their messages
|
||||
*/
|
||||
static parseSessionsJsonl(text: string): ExportedConversation[] {
|
||||
const sessions: ExportedConversation[] = [];
|
||||
|
||||
let current: ExportedConversation | null = null;
|
||||
|
||||
for (const line of text.split(NEWLINE)) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (!trimmed) continue;
|
||||
|
||||
const record = JSON.parse(trimmed);
|
||||
|
||||
if (record.type === SessionRecordType.SESSION) {
|
||||
// Drop the discriminator and harness marker; the rest is the conversation.
|
||||
const conv = { ...record };
|
||||
|
||||
delete conv.type;
|
||||
delete conv.harness;
|
||||
current = { conv: conv as DatabaseConversation, messages: [] };
|
||||
sessions.push(current);
|
||||
} else if (record.type === SessionRecordType.MESSAGE) {
|
||||
if (!current) {
|
||||
throw new Error('Invalid JSONL: message record before any session record');
|
||||
}
|
||||
|
||||
const message = record.message as DatabaseMessage;
|
||||
|
||||
// `toolCalls` is parsed to an array on export; the DB stores it as a string.
|
||||
if (message.toolCalls !== undefined && typeof message.toolCalls !== 'string') {
|
||||
message.toolCalls = JSON.stringify(message.toolCalls);
|
||||
}
|
||||
|
||||
current.messages.push(message);
|
||||
}
|
||||
// Ignore unknown record types for forward compatibility.
|
||||
}
|
||||
|
||||
return sessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a session (a conversation with its messages) as JSONL.
|
||||
* The first line is the session header (a `SessionRecordType.SESSION` record
|
||||
* carrying the conversation properties); each subsequent line is a single message.
|
||||
* @param data - The exported conversation payload
|
||||
* @returns The JSONL string (one record per line)
|
||||
*/
|
||||
static serializeSessionToJsonl(data: ExportedConversation): string {
|
||||
const { conv, messages } = data;
|
||||
const sessionLine = JSON.stringify({
|
||||
harness: EXPORT_CONV.HARNESS,
|
||||
type: SessionRecordType.SESSION,
|
||||
...conv
|
||||
});
|
||||
const messageLines = messages.map((message: DatabaseMessage) => {
|
||||
// `toolCalls` is stored as a JSON string; drop it when empty, otherwise parse it.
|
||||
const { toolCalls, ...rest } = message;
|
||||
const normalized = toolCalls ? { ...rest, toolCalls: JSON.parse(toolCalls) } : rest;
|
||||
|
||||
return JSON.stringify({ message: normalized, type: SessionRecordType.MESSAGE });
|
||||
});
|
||||
|
||||
return [sessionLine, ...messageLines].join(NEWLINE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether the text is the JSONL session format, whose first non-empty
|
||||
* line is a `SessionRecordType.SESSION` record. A legacy JSON export starts
|
||||
* with an array or an object that has no such discriminator.
|
||||
* @param text - The file contents
|
||||
*/
|
||||
private static isSessionsJsonl(text: string): boolean {
|
||||
const trimmed = text.trimStart();
|
||||
const lineEnd = trimmed.indexOf(NEWLINE);
|
||||
const firstLine = lineEnd === -1 ? trimmed : trimmed.slice(0, lineEnd);
|
||||
|
||||
try {
|
||||
return JSON.parse(firstLine).type === SessionRecordType.SESSION;
|
||||
} catch {
|
||||
// Not a standalone JSON record, so not the JSONL format.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a browser download of a blob under the given filename.
|
||||
*/
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
/**
|
||||
* DatabaseService - IndexedDB persistence for conversations and messages
|
||||
*
|
||||
* Thin Dexie layer over the conversations/messages tables: CRUD, tree
|
||||
* navigation (descendants, reparenting) and cascading deletes. No reactive
|
||||
* state; consumed by conversationsStore and the chat flows.
|
||||
*/
|
||||
|
||||
import { IDXDB_STORES, IDXDB_TABLES, STORAGE_APP_NAME } from '$lib/constants';
|
||||
import { MessageRole } from '$lib/enums';
|
||||
import type { McpServerOverride } from '$lib/types/database';
|
||||
@@ -20,12 +28,99 @@ const db = new LlamaUiDatabase();
|
||||
|
||||
export class DatabaseService {
|
||||
/**
|
||||
* Deletes multiple conversations in a single transaction. Each deleted
|
||||
* conversation has its direct children reparented to the nearest surviving
|
||||
* ancestor (or promoted to top-level). Children also in `ids` are dropped
|
||||
* entirely rather than reparented.
|
||||
*
|
||||
*
|
||||
* Conversations
|
||||
*
|
||||
*
|
||||
* @param ids - Conversation IDs to delete
|
||||
*/
|
||||
static async bulkDeleteConversations(ids: string[]): Promise<void> {
|
||||
const cleanIds = ids.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
||||
|
||||
if (cleanIds.length === 0) return;
|
||||
|
||||
const idSet = new Set(cleanIds);
|
||||
|
||||
await db.transaction(
|
||||
'rw',
|
||||
[db[IDXDB_TABLES.conversations], db[IDXDB_TABLES.messages]],
|
||||
async () => {
|
||||
// Pre-load each to-delete conversation so the per-id reparent
|
||||
// walk-up doesn't ping-pong the same ancestry chain.
|
||||
const prefetched = new Map<string, DatabaseConversation>();
|
||||
|
||||
let frontier = [...cleanIds];
|
||||
|
||||
const requested = new Set<string>(frontier);
|
||||
|
||||
while (frontier.length > 0) {
|
||||
const fetched = await db[IDXDB_TABLES.conversations].bulkGet(frontier);
|
||||
|
||||
frontier = [];
|
||||
for (let i = 0; i < fetched.length; i++) {
|
||||
const conv = fetched[i];
|
||||
|
||||
if (!conv || !conv.id) continue;
|
||||
|
||||
prefetched.set(conv.id, conv);
|
||||
const ancestor = conv.forkedFromConversationId;
|
||||
|
||||
if (ancestor && !prefetched.has(ancestor) && !requested.has(ancestor)) {
|
||||
frontier.push(ancestor);
|
||||
requested.add(ancestor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of cleanIds) {
|
||||
await this.reparentDirectChildren(id, idSet, prefetched);
|
||||
}
|
||||
|
||||
await db[IDXDB_TABLES.conversations].bulkDelete(cleanIds);
|
||||
await db[IDXDB_TABLES.messages].where('convId').anyOf(cleanIds).delete();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the pinned status of each conversation in `ids` inside a single
|
||||
* transaction. Treats `pinned === undefined` as `false`, matching the
|
||||
* semantics of {@link toggleConversationPin} where `!undefined` evaluates
|
||||
* to `true`. Returns the resulting pinned state for every id that was
|
||||
* updated; missing ids are omitted from the map.
|
||||
*
|
||||
* @param ids - Conversation IDs to toggle
|
||||
* @returns Map of id -> new pinned state
|
||||
*/
|
||||
static async bulkToggleConversationPins(ids: string[]): Promise<Map<string, boolean>> {
|
||||
const cleanIds = ids.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
||||
const result = new Map<string, boolean>();
|
||||
|
||||
if (cleanIds.length === 0) return result;
|
||||
|
||||
await db.transaction('rw', db[IDXDB_TABLES.conversations], async () => {
|
||||
const convs = await db[IDXDB_TABLES.conversations].bulkGet(cleanIds);
|
||||
const updates: DatabaseConversation[] = [];
|
||||
|
||||
for (let i = 0; i < cleanIds.length; i++) {
|
||||
const conv = convs[i];
|
||||
|
||||
if (!conv) continue;
|
||||
|
||||
const newPinned = !conv.pinned;
|
||||
|
||||
updates.push({ ...conv, pinned: newPinned });
|
||||
result.set(cleanIds[i], newPinned);
|
||||
}
|
||||
|
||||
if (updates.length === 0) return;
|
||||
|
||||
await db[IDXDB_TABLES.conversations].bulkPut(updates);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new conversation.
|
||||
@@ -51,14 +146,6 @@ export class DatabaseService {
|
||||
return conversation;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Messages
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates a new message branch by adding a message and updating parent/child relationships.
|
||||
* Also updates the conversation's currNode to point to the new message.
|
||||
@@ -96,13 +183,7 @@ export class DatabaseService {
|
||||
|
||||
// Update parent's children array if parent exists
|
||||
if (parentId !== null) {
|
||||
const parentMessage = await db[IDXDB_TABLES.messages].get(parentId);
|
||||
|
||||
if (parentMessage) {
|
||||
await db[IDXDB_TABLES.messages].update(parentId, {
|
||||
children: [...parentMessage.children, newMessage.id]
|
||||
});
|
||||
}
|
||||
await this.addChildToParent(parentId, newMessage.id);
|
||||
}
|
||||
|
||||
await this.updateConversation(message.convId, {
|
||||
@@ -178,9 +259,7 @@ export class DatabaseService {
|
||||
};
|
||||
|
||||
await db[IDXDB_TABLES.messages].add(systemMessage);
|
||||
await db[IDXDB_TABLES.messages].update(parentId, {
|
||||
children: [...parentMessage.children, systemMessage.id]
|
||||
});
|
||||
await this.addChildToParent(parentId, systemMessage.id);
|
||||
|
||||
return systemMessage;
|
||||
});
|
||||
@@ -230,121 +309,6 @@ export class DatabaseService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reparents direct children of `parentId` to the nearest surviving
|
||||
* ancestor (or promotes them to top-level when the immediate parent was
|
||||
* top-level). Walking skips any ancestor listed in `excludeIds`, since
|
||||
* those will be deleted in the same batch — leaving a grandchild pointing
|
||||
* at an `excludeIds` entry would orphan it. Children whose own id is in
|
||||
* `excludeIds` are dropped from the updates (the bulk-delete pass will
|
||||
* remove them). `prefetched` may carry a pre-fetched ancestor map to
|
||||
* avoid repeat reads inside a bulk transaction.
|
||||
*/
|
||||
private static async reparentDirectChildren(
|
||||
parentId: string,
|
||||
excludeIds: ReadonlySet<string> = new Set(),
|
||||
prefetched?: ReadonlyMap<string, DatabaseConversation>
|
||||
): Promise<void> {
|
||||
const conv = prefetched?.get(parentId) ?? (await db[IDXDB_TABLES.conversations].get(parentId));
|
||||
|
||||
if (!conv) return;
|
||||
|
||||
let newParent = conv.forkedFromConversationId;
|
||||
|
||||
const visited = new Set<string>([parentId]);
|
||||
|
||||
while (newParent && excludeIds.has(newParent)) {
|
||||
if (visited.has(newParent)) {
|
||||
newParent = undefined;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
visited.add(newParent);
|
||||
const next =
|
||||
prefetched?.get(newParent) ?? (await db[IDXDB_TABLES.conversations].get(newParent));
|
||||
|
||||
if (!next) {
|
||||
newParent = undefined;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
newParent = next.forkedFromConversationId;
|
||||
}
|
||||
|
||||
const directChildren = await db[IDXDB_TABLES.conversations]
|
||||
.filter((c) => c.forkedFromConversationId === parentId)
|
||||
.toArray();
|
||||
const updates: DatabaseConversation[] = [];
|
||||
|
||||
for (const child of directChildren) {
|
||||
if (excludeIds.has(child.id)) continue;
|
||||
|
||||
updates.push({ ...child, forkedFromConversationId: newParent });
|
||||
}
|
||||
|
||||
if (updates.length === 0) return;
|
||||
|
||||
await db[IDXDB_TABLES.conversations].bulkPut(updates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes multiple conversations in a single transaction. Each deleted
|
||||
* conversation has its direct children reparented to the nearest surviving
|
||||
* ancestor (or promoted to top-level). Children also in `ids` are dropped
|
||||
* entirely rather than reparented.
|
||||
*
|
||||
* @param ids - Conversation IDs to delete
|
||||
*/
|
||||
static async bulkDeleteConversations(ids: string[]): Promise<void> {
|
||||
const cleanIds = ids.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
||||
|
||||
if (cleanIds.length === 0) return;
|
||||
|
||||
const idSet = new Set(cleanIds);
|
||||
|
||||
await db.transaction(
|
||||
'rw',
|
||||
[db[IDXDB_TABLES.conversations], db[IDXDB_TABLES.messages]],
|
||||
async () => {
|
||||
// Pre-load each to-delete conversation so the per-id reparent
|
||||
// walk-up doesn't ping-pong the same ancestry chain.
|
||||
const prefetched = new Map<string, DatabaseConversation>();
|
||||
|
||||
let frontier = [...cleanIds];
|
||||
|
||||
const requested = new Set<string>(frontier);
|
||||
|
||||
while (frontier.length > 0) {
|
||||
const fetched = await db[IDXDB_TABLES.conversations].bulkGet(frontier);
|
||||
|
||||
frontier = [];
|
||||
for (let i = 0; i < fetched.length; i++) {
|
||||
const conv = fetched[i];
|
||||
|
||||
if (!conv || !conv.id) continue;
|
||||
|
||||
prefetched.set(conv.id, conv);
|
||||
const ancestor = conv.forkedFromConversationId;
|
||||
|
||||
if (ancestor && !prefetched.has(ancestor) && !requested.has(ancestor)) {
|
||||
frontier.push(ancestor);
|
||||
requested.add(ancestor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of cleanIds) {
|
||||
await this.reparentDirectChildren(id, idSet, prefetched);
|
||||
}
|
||||
|
||||
await db[IDXDB_TABLES.conversations].bulkDelete(cleanIds);
|
||||
await db[IDXDB_TABLES.messages].where('convId').anyOf(cleanIds).delete();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a message and removes it from its parent's children array.
|
||||
*
|
||||
@@ -356,17 +320,8 @@ export class DatabaseService {
|
||||
|
||||
if (!message) return;
|
||||
|
||||
// Remove this message from its parent's children array
|
||||
if (message.parent) {
|
||||
const parent = await db[IDXDB_TABLES.messages].get(message.parent);
|
||||
await this.removeChildFromParent(messageId);
|
||||
|
||||
if (parent) {
|
||||
parent.children = parent.children.filter((childId: string) => childId !== messageId);
|
||||
await db[IDXDB_TABLES.messages].put(parent);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the message
|
||||
await db[IDXDB_TABLES.messages].delete(messageId);
|
||||
});
|
||||
}
|
||||
@@ -389,20 +344,10 @@ export class DatabaseService {
|
||||
.where('convId')
|
||||
.equals(conversationId)
|
||||
.toArray();
|
||||
// Find all descendant messages
|
||||
const descendants = findDescendantMessages(allMessages, messageId);
|
||||
const allToDelete = [messageId, ...descendants];
|
||||
// Get the message to delete for parent cleanup
|
||||
const message = await db[IDXDB_TABLES.messages].get(messageId);
|
||||
|
||||
if (message && message.parent) {
|
||||
const parent = await db[IDXDB_TABLES.messages].get(message.parent);
|
||||
|
||||
if (parent) {
|
||||
parent.children = parent.children.filter((childId: string) => childId !== messageId);
|
||||
await db[IDXDB_TABLES.messages].put(parent);
|
||||
}
|
||||
}
|
||||
await this.removeChildFromParent(messageId);
|
||||
|
||||
// Delete all messages in the branch
|
||||
await db[IDXDB_TABLES.messages].bulkDelete(allToDelete);
|
||||
@@ -411,243 +356,6 @@ export class DatabaseService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all conversations, sorted by last modified time (newest first).
|
||||
*
|
||||
* @returns Array of conversations
|
||||
*/
|
||||
static async getAllConversations(): Promise<DatabaseConversation[]> {
|
||||
return await db[IDXDB_TABLES.conversations].orderBy('lastModified').reverse().toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a conversation by ID.
|
||||
*
|
||||
* @param id - Conversation ID
|
||||
* @returns The conversation if found, otherwise undefined
|
||||
*/
|
||||
static async getConversation(id: string): Promise<DatabaseConversation | undefined> {
|
||||
return await db[IDXDB_TABLES.conversations].get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all messages in a conversation, sorted by timestamp (oldest first).
|
||||
*
|
||||
* @param convId - Conversation ID
|
||||
* @returns Array of messages in the conversation
|
||||
*/
|
||||
static async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
|
||||
return await db[IDXDB_TABLES.messages].where('convId').equals(convId).sortBy('timestamp');
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads multiple conversations with all of their messages in two bulk
|
||||
* reads. Missing conversations are silently omitted from the result.
|
||||
*
|
||||
* @param convIds - Conversation IDs to load
|
||||
* @returns Map of id -> { conv, messages }. Messages are sorted ascending by timestamp.
|
||||
*/
|
||||
static async getConversationsWithMessages(
|
||||
convIds: string[]
|
||||
): Promise<Map<string, ExportedConversation>> {
|
||||
const result = new Map<string, ExportedConversation>();
|
||||
const cleanIds = convIds.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
||||
|
||||
if (cleanIds.length === 0) return result;
|
||||
|
||||
const [convs, allMessages] = await Promise.all([
|
||||
db[IDXDB_TABLES.conversations].bulkGet(cleanIds),
|
||||
db[IDXDB_TABLES.messages].where('convId').anyOf(cleanIds).toArray()
|
||||
]);
|
||||
const messagesByConv = new Map<string, DatabaseMessage[]>();
|
||||
|
||||
for (const msg of allMessages) {
|
||||
const bucket = messagesByConv.get(msg.convId);
|
||||
|
||||
if (bucket) bucket.push(msg);
|
||||
else messagesByConv.set(msg.convId, [msg]);
|
||||
}
|
||||
|
||||
for (let i = 0; i < cleanIds.length; i++) {
|
||||
const conv = convs[i];
|
||||
|
||||
if (!conv) continue;
|
||||
|
||||
const messages = (messagesByConv.get(conv.id) ?? []).sort(
|
||||
(a, b) => a.timestamp - b.timestamp
|
||||
);
|
||||
|
||||
result.set(conv.id, { conv, messages });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a conversation. `lastModified` is never stamped implicitly;
|
||||
* pass it in `updates` to bump the conversation in recency ordering.
|
||||
*
|
||||
* @param id - Conversation ID
|
||||
* @param updates - Partial updates to apply
|
||||
* @returns Promise that resolves when the conversation is updated
|
||||
*/
|
||||
static async updateConversation(
|
||||
id: string,
|
||||
updates: Partial<Omit<DatabaseConversation, 'id'>>
|
||||
): Promise<void> {
|
||||
await db[IDXDB_TABLES.conversations].update(id, updates);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Navigation
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Toggles the pinned status of a conversation.
|
||||
*
|
||||
* @param id - Conversation ID
|
||||
* @returns The new pinned status
|
||||
*/
|
||||
static async toggleConversationPin(id: string): Promise<boolean> {
|
||||
const conversation = await db[IDXDB_TABLES.conversations].get(id);
|
||||
|
||||
if (!conversation) {
|
||||
throw new Error(`Conversation ${id} not found`);
|
||||
}
|
||||
|
||||
const newPinnedState = !conversation.pinned;
|
||||
|
||||
await this.updateConversation(id, { pinned: newPinnedState });
|
||||
|
||||
return newPinnedState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the pinned status of each conversation in `ids` inside a single
|
||||
* transaction. Treats `pinned === undefined` as `false`, matching the
|
||||
* semantics of {@link toggleConversationPin} where `!undefined` evaluates
|
||||
* to `true`. Returns the resulting pinned state for every id that was
|
||||
* updated; missing ids are omitted from the map.
|
||||
*
|
||||
* @param ids - Conversation IDs to toggle
|
||||
* @returns Map of id -> new pinned state
|
||||
*/
|
||||
static async bulkToggleConversationPins(ids: string[]): Promise<Map<string, boolean>> {
|
||||
const cleanIds = ids.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
||||
const result = new Map<string, boolean>();
|
||||
|
||||
if (cleanIds.length === 0) return result;
|
||||
|
||||
await db.transaction('rw', db[IDXDB_TABLES.conversations], async () => {
|
||||
const convs = await db[IDXDB_TABLES.conversations].bulkGet(cleanIds);
|
||||
const updates: DatabaseConversation[] = [];
|
||||
|
||||
for (let i = 0; i < cleanIds.length; i++) {
|
||||
const conv = convs[i];
|
||||
|
||||
if (!conv) continue;
|
||||
|
||||
const newPinned = !conv.pinned;
|
||||
|
||||
updates.push({ ...conv, pinned: newPinned });
|
||||
result.set(cleanIds[i], newPinned);
|
||||
}
|
||||
|
||||
if (updates.length === 0) return;
|
||||
|
||||
await db[IDXDB_TABLES.conversations].bulkPut(updates);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the conversation's current node (active branch).
|
||||
* This determines which conversation path is currently being viewed.
|
||||
*
|
||||
* @param convId - Conversation ID
|
||||
* @param nodeId - Message ID to set as current node
|
||||
*/
|
||||
static async updateCurrentNode(convId: string, nodeId: string): Promise<void> {
|
||||
await this.updateConversation(convId, {
|
||||
currNode: nodeId
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a message.
|
||||
*
|
||||
* @param id - Message ID
|
||||
* @param updates - Partial updates to apply
|
||||
* @returns Promise that resolves when the message is updated
|
||||
*/
|
||||
static async updateMessage(
|
||||
id: string,
|
||||
updates: Partial<Omit<DatabaseMessage, 'id'>>
|
||||
): Promise<void> {
|
||||
await db[IDXDB_TABLES.messages].update(id, updates);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Import
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Imports multiple conversations and their messages.
|
||||
* Skips conversations that already exist.
|
||||
*
|
||||
* @param data - Array of { conv, messages } objects
|
||||
* @returns The conversations written to the database and the ones skipped
|
||||
*/
|
||||
static async importConversations(
|
||||
data: { conv: DatabaseConversation; messages: DatabaseMessage[] }[]
|
||||
): Promise<{ imported: DatabaseConversation[]; skipped: DatabaseConversation[] }> {
|
||||
const imported: DatabaseConversation[] = [];
|
||||
const skipped: DatabaseConversation[] = [];
|
||||
|
||||
return await db.transaction(
|
||||
'rw',
|
||||
[db[IDXDB_TABLES.conversations], db[IDXDB_TABLES.messages]],
|
||||
async () => {
|
||||
for (const item of data) {
|
||||
const { conv, messages } = item;
|
||||
const existing = await db[IDXDB_TABLES.conversations].get(conv.id);
|
||||
|
||||
if (existing) {
|
||||
skipped.push(conv);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
await db[IDXDB_TABLES.conversations].add(conv);
|
||||
for (const msg of messages) {
|
||||
await db[IDXDB_TABLES.messages].put(msg);
|
||||
}
|
||||
|
||||
imported.push(conv);
|
||||
}
|
||||
|
||||
return { imported, skipped };
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Forking
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Forks a conversation at a specific message, creating a new conversation
|
||||
* containing all messages from the root up to (and including) the target message.
|
||||
@@ -726,13 +434,272 @@ export class DatabaseService {
|
||||
};
|
||||
|
||||
await db[IDXDB_TABLES.conversations].add(newConv);
|
||||
|
||||
for (const msg of clonedMessages) {
|
||||
await db[IDXDB_TABLES.messages].add(msg);
|
||||
}
|
||||
await db[IDXDB_TABLES.messages].bulkAdd(clonedMessages);
|
||||
|
||||
return newConv;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all conversations, sorted by last modified time (newest first).
|
||||
*
|
||||
* @returns Array of conversations
|
||||
*/
|
||||
static async getAllConversations(): Promise<DatabaseConversation[]> {
|
||||
return await db[IDXDB_TABLES.conversations].orderBy('lastModified').reverse().toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a conversation by ID.
|
||||
*
|
||||
* @param id - Conversation ID
|
||||
* @returns The conversation if found, otherwise undefined
|
||||
*/
|
||||
static async getConversation(id: string): Promise<DatabaseConversation | undefined> {
|
||||
return await db[IDXDB_TABLES.conversations].get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all messages in a conversation, sorted by timestamp (oldest first).
|
||||
*
|
||||
* @param convId - Conversation ID
|
||||
* @returns Array of messages in the conversation
|
||||
*/
|
||||
static async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
|
||||
return await db[IDXDB_TABLES.messages].where('convId').equals(convId).sortBy('timestamp');
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads multiple conversations with all of their messages in two bulk
|
||||
* reads. Missing conversations are silently omitted from the result.
|
||||
*
|
||||
* @param convIds - Conversation IDs to load
|
||||
* @returns Map of id -> { conv, messages }. Messages are sorted ascending by timestamp.
|
||||
*/
|
||||
static async getConversationsWithMessages(
|
||||
convIds: string[]
|
||||
): Promise<Map<string, ExportedConversation>> {
|
||||
const result = new Map<string, ExportedConversation>();
|
||||
const cleanIds = convIds.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
||||
|
||||
if (cleanIds.length === 0) return result;
|
||||
|
||||
const [convs, allMessages] = await Promise.all([
|
||||
db[IDXDB_TABLES.conversations].bulkGet(cleanIds),
|
||||
db[IDXDB_TABLES.messages].where('convId').anyOf(cleanIds).toArray()
|
||||
]);
|
||||
const messagesByConv = new Map<string, DatabaseMessage[]>();
|
||||
|
||||
for (const msg of allMessages) {
|
||||
const bucket = messagesByConv.get(msg.convId);
|
||||
|
||||
if (bucket) bucket.push(msg);
|
||||
else messagesByConv.set(msg.convId, [msg]);
|
||||
}
|
||||
|
||||
for (let i = 0; i < cleanIds.length; i++) {
|
||||
const conv = convs[i];
|
||||
|
||||
if (!conv) continue;
|
||||
|
||||
const messages = (messagesByConv.get(conv.id) ?? []).sort(
|
||||
(a, b) => a.timestamp - b.timestamp
|
||||
);
|
||||
|
||||
result.set(conv.id, { conv, messages });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports multiple conversations and their messages.
|
||||
* Skips conversations that already exist.
|
||||
*
|
||||
* @param data - Array of { conv, messages } objects
|
||||
* @returns The conversations written to the database and the ones skipped
|
||||
*/
|
||||
static async importConversations(
|
||||
data: { conv: DatabaseConversation; messages: DatabaseMessage[] }[]
|
||||
): Promise<{ imported: DatabaseConversation[]; skipped: DatabaseConversation[] }> {
|
||||
const imported: DatabaseConversation[] = [];
|
||||
const skipped: DatabaseConversation[] = [];
|
||||
|
||||
return await db.transaction(
|
||||
'rw',
|
||||
[db[IDXDB_TABLES.conversations], db[IDXDB_TABLES.messages]],
|
||||
async () => {
|
||||
for (const item of data) {
|
||||
const { conv, messages } = item;
|
||||
const existing = await db[IDXDB_TABLES.conversations].get(conv.id);
|
||||
|
||||
if (existing) {
|
||||
skipped.push(conv);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
await db[IDXDB_TABLES.conversations].add(conv);
|
||||
for (const msg of messages) {
|
||||
await db[IDXDB_TABLES.messages].put(msg);
|
||||
}
|
||||
|
||||
imported.push(conv);
|
||||
}
|
||||
|
||||
return { imported, skipped };
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the pinned status of a conversation.
|
||||
*
|
||||
* @param id - Conversation ID
|
||||
* @returns The new pinned status
|
||||
*/
|
||||
static async toggleConversationPin(id: string): Promise<boolean> {
|
||||
const conversation = await db[IDXDB_TABLES.conversations].get(id);
|
||||
|
||||
if (!conversation) {
|
||||
throw new Error(`Conversation ${id} not found`);
|
||||
}
|
||||
|
||||
const newPinnedState = !conversation.pinned;
|
||||
|
||||
await this.updateConversation(id, { pinned: newPinnedState });
|
||||
|
||||
return newPinnedState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a conversation. `lastModified` is never stamped implicitly;
|
||||
* pass it in `updates` to bump the conversation in recency ordering.
|
||||
*
|
||||
* @param id - Conversation ID
|
||||
* @param updates - Partial updates to apply
|
||||
* @returns Promise that resolves when the conversation is updated
|
||||
*/
|
||||
static async updateConversation(
|
||||
id: string,
|
||||
updates: Partial<Omit<DatabaseConversation, 'id'>>
|
||||
): Promise<void> {
|
||||
await db[IDXDB_TABLES.conversations].update(id, updates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the conversation's current node (active branch).
|
||||
* This determines which conversation path is currently being viewed.
|
||||
*
|
||||
* @param convId - Conversation ID
|
||||
* @param nodeId - Message ID to set as current node
|
||||
*/
|
||||
static async updateCurrentNode(convId: string, nodeId: string): Promise<void> {
|
||||
await this.updateConversation(convId, {
|
||||
currNode: nodeId
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a message.
|
||||
*
|
||||
* @param id - Message ID
|
||||
* @param updates - Partial updates to apply
|
||||
* @returns Promise that resolves when the message is updated
|
||||
*/
|
||||
static async updateMessage(
|
||||
id: string,
|
||||
updates: Partial<Omit<DatabaseMessage, 'id'>>
|
||||
): Promise<void> {
|
||||
await db[IDXDB_TABLES.messages].update(id, updates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a child id to a parent message's children array.
|
||||
*/
|
||||
private static async addChildToParent(parentId: string, childId: string): Promise<void> {
|
||||
const parent = await db[IDXDB_TABLES.messages].get(parentId);
|
||||
|
||||
if (!parent) return;
|
||||
|
||||
await db[IDXDB_TABLES.messages].update(parentId, {
|
||||
children: [...parent.children, childId]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a child id from its parent message's children array.
|
||||
*/
|
||||
private static async removeChildFromParent(messageId: string): Promise<void> {
|
||||
const message = await db[IDXDB_TABLES.messages].get(messageId);
|
||||
|
||||
if (!message?.parent) return;
|
||||
|
||||
const parent = await db[IDXDB_TABLES.messages].get(message.parent);
|
||||
|
||||
if (!parent) return;
|
||||
|
||||
parent.children = parent.children.filter((childId: string) => childId !== messageId);
|
||||
await db[IDXDB_TABLES.messages].put(parent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reparents direct children of `parentId` to the nearest surviving
|
||||
* ancestor (or promotes them to top-level when the immediate parent was
|
||||
* top-level). Walking skips any ancestor listed in `excludeIds`, since
|
||||
* those will be deleted in the same batch — leaving a grandchild pointing
|
||||
* at an `excludeIds` entry would orphan it. Children whose own id is in
|
||||
* `excludeIds` are dropped from the updates (the bulk-delete pass will
|
||||
* remove them). `prefetched` may carry a pre-fetched ancestor map to
|
||||
* avoid repeat reads inside a bulk transaction.
|
||||
*/
|
||||
private static async reparentDirectChildren(
|
||||
parentId: string,
|
||||
excludeIds: ReadonlySet<string> = new Set(),
|
||||
prefetched?: ReadonlyMap<string, DatabaseConversation>
|
||||
): Promise<void> {
|
||||
const conv = prefetched?.get(parentId) ?? (await db[IDXDB_TABLES.conversations].get(parentId));
|
||||
|
||||
if (!conv) return;
|
||||
|
||||
let newParent = conv.forkedFromConversationId;
|
||||
|
||||
const visited = new Set<string>([parentId]);
|
||||
|
||||
while (newParent && excludeIds.has(newParent)) {
|
||||
if (visited.has(newParent)) {
|
||||
newParent = undefined;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
visited.add(newParent);
|
||||
const next =
|
||||
prefetched?.get(newParent) ?? (await db[IDXDB_TABLES.conversations].get(newParent));
|
||||
|
||||
if (!next) {
|
||||
newParent = undefined;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
newParent = next.forkedFromConversationId;
|
||||
}
|
||||
|
||||
const directChildren = await db[IDXDB_TABLES.conversations]
|
||||
.filter((c) => c.forkedFromConversationId === parentId)
|
||||
.toArray();
|
||||
const updates: DatabaseConversation[] = [];
|
||||
|
||||
for (const child of directChildren) {
|
||||
if (excludeIds.has(child.id)) continue;
|
||||
|
||||
updates.push({ ...child, forkedFromConversationId: newParent });
|
||||
}
|
||||
|
||||
if (updates.length === 0) return;
|
||||
|
||||
await db[IDXDB_TABLES.conversations].bulkPut(updates);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,9 +53,9 @@
|
||||
* - Reasoning content stripping from prompt history to avoid KV cache pollution
|
||||
* - Error translation (network, timeout, server errors → user-friendly messages)
|
||||
*
|
||||
* @see chatStore in stores/chat.svelte.ts — primary consumer for chat state management
|
||||
* @see agenticStore in stores/agentic.svelte.ts — uses ChatService for agentic loop streaming
|
||||
* @see conversationsStore in stores/conversations.svelte.ts — provides message context
|
||||
* @see chatStore in stores/chat/index.svelte.ts — primary consumer for chat state management
|
||||
* @see agenticStore in stores/agentic/index.svelte.ts — uses ChatService for agentic loop streaming
|
||||
* @see conversationsStore in stores/conversations/index.svelte.ts — provides message context
|
||||
*/
|
||||
export { ChatService } from './chat.service';
|
||||
|
||||
@@ -98,8 +98,8 @@ export { ChatService } from './chat.service';
|
||||
* enabling conversation branching and alternative response paths. The conversation's
|
||||
* `currNode` tracks the currently active branch endpoint.
|
||||
*
|
||||
* @see conversationsStore in stores/conversations.svelte.ts — reactive layer on top of DatabaseService
|
||||
* @see chatStore in stores/chat.svelte.ts — uses DatabaseService directly for message CRUD during streaming
|
||||
* @see conversationsStore in stores/conversations/index.svelte.ts — reactive layer on top of DatabaseService
|
||||
* @see chatStore in stores/chat/index.svelte.ts — uses DatabaseService directly for message CRUD during streaming
|
||||
*/
|
||||
export { DatabaseService } from './database.service';
|
||||
|
||||
@@ -143,7 +143,7 @@ export { ConversationTransferService } from './conversation-transfer.service';
|
||||
* - `POST /models/load` — Load a model (ROUTER mode only)
|
||||
* - `POST /models/unload` — Unload a model (ROUTER mode only)
|
||||
*
|
||||
* @see modelsStore in stores/models.svelte.ts — primary consumer for reactive model state
|
||||
* @see modelsStore in stores/models/index.svelte.ts — primary consumer for reactive model state
|
||||
*/
|
||||
export { ModelsService } from './models.service';
|
||||
|
||||
@@ -174,8 +174,8 @@ export { ModelsService } from './models.service';
|
||||
* - `&autoload=false` → Prevents model auto-loading when querying props
|
||||
*
|
||||
* @see serverStore in stores/server.svelte.ts — consumes global server props
|
||||
* @see modelsStore in stores/models.svelte.ts — consumes per-model props for modalities
|
||||
* @see settingsStore in stores/settings.svelte.ts — syncs default generation params from props
|
||||
* @see modelsStore in stores/models/index.svelte.ts — consumes per-model props for modalities
|
||||
* @see settingsStore in stores/settings/index.svelte.ts — syncs default generation params from props
|
||||
*/
|
||||
export { PropsService } from './props.service';
|
||||
|
||||
@@ -217,7 +217,7 @@ export { PropsService } from './props.service';
|
||||
* - `ParameterSyncService` class — static methods for sync logic
|
||||
* - `SYNCABLE_PARAMETERS` — mapping of UI setting keys to server parameter keys
|
||||
*
|
||||
* @see settingsStore in stores/settings.svelte.ts — primary consumer for settings sync
|
||||
* @see settingsStore in stores/settings/index.svelte.ts — primary consumer for settings sync
|
||||
* @see SettingsChatParameterSourceIndicator — displays parameter source badges in UI
|
||||
*/
|
||||
export { ParameterSyncService } from './parameter-sync.service';
|
||||
@@ -241,7 +241,7 @@ export { ParameterSyncService } from './parameter-sync.service';
|
||||
* - Manages connection lifecycle, health checks, reconnection
|
||||
* - Handles tool name conflict resolution and server coordination
|
||||
*
|
||||
* - **mcpResourceStore**: Reactive resource state
|
||||
* - **mcpResourceStore** (composed as mcpStore.resources): Reactive resource state
|
||||
* - Receives resource data fetched via MCPService
|
||||
* - Manages resource caching, subscriptions, and attachments
|
||||
*
|
||||
@@ -263,9 +263,9 @@ export { ParameterSyncService } from './parameter-sync.service';
|
||||
* 2. **StreamableHTTP** — modern HTTP-based, supports CORS proxy
|
||||
* 3. **SSE** — legacy fallback, supports CORS proxy
|
||||
*
|
||||
* @see mcpStore in stores/mcp.svelte.ts — reactive business logic facade on top of MCPService
|
||||
* @see mcpResourceStore in stores/mcp-resources.svelte.ts — reactive resource state management
|
||||
* @see agenticStore in stores/agentic.svelte.ts — uses MCPService (via mcpStore) for tool execution
|
||||
* @see mcpStore in stores/mcp/index.svelte.ts — reactive business logic facade on top of MCPService
|
||||
* @see mcpStore.resources in stores/mcp/resources.svelte.ts — reactive resource state management
|
||||
* @see agenticStore in stores/agentic/index.svelte.ts — uses MCPService (via mcpStore) for tool execution
|
||||
* @see MCP Protocol Specification: https://modelcontextprotocol.io/specification/2025-06-18
|
||||
*/
|
||||
export { MCPService } from './mcp.service';
|
||||
@@ -286,7 +286,7 @@ export { MCPService } from './mcp.service';
|
||||
* - **agenticStore**: Dispatches ToolSource.BROWSER calls here
|
||||
*
|
||||
* @see buildSandboxToolDefinition in utils/sandbox-tool - tool schema sent to the LLM
|
||||
* @see agenticStore in stores/agentic.svelte.ts - tool dispatch
|
||||
* @see agenticStore in stores/agentic/index.svelte.ts - tool dispatch
|
||||
*/
|
||||
export { SandboxService } from './sandbox.service';
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,11 @@
|
||||
/**
|
||||
* Migration Service - Unified data migration hook
|
||||
* MigrationService - Unified data migration hook
|
||||
*
|
||||
* Centralizes all data migrations (localStorage, IndexedDB, legacy formats) into a single
|
||||
* initialization point. Each migration copies data to new format WITHOUT deleting the old.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Migrations are defined as objects with `id` and `run()` methods
|
||||
* - Migration state is tracked in localStorage to avoid re-running
|
||||
* - `runAllMigrations()` should be called once at app startup
|
||||
* - All migrations are NON-DESTRUCTIVE - legacy data is preserved for downgrade compatibility
|
||||
*
|
||||
* **Current Migrations:**
|
||||
* 1. localStorage prefix: Copy LlamaCppWebui.* → LlamaUi.* (both preserved)
|
||||
* 2. IndexedDB database: Copy LlamacppWebui → LlamaUi (both preserved)
|
||||
* 3. Legacy message format: Transform in-place (preserves structure, migrates markers)
|
||||
* 4. Theme key: Copy standalone `theme` → config object (both preserved)
|
||||
* Centralizes all data migrations (localStorage, IndexedDB, legacy formats)
|
||||
* into a single initialization point. Each migration copies data to the new
|
||||
* format WITHOUT deleting the old, and state is tracked in localStorage so
|
||||
* `runAllMigrations()` (called once at startup) never re-runs a completed
|
||||
* migration. All migrations are non-destructive for downgrade compatibility.
|
||||
*/
|
||||
|
||||
import {
|
||||
|
||||
@@ -1,25 +1,55 @@
|
||||
/**
|
||||
* ModelsService - Stateless model management API layer
|
||||
*
|
||||
* Wraps the /models endpoints (list, load, unload) and the /models/sse
|
||||
* status feed in MODEL and ROUTER modes. No reactive state; consumed by
|
||||
* modelsStore and its status manager.
|
||||
*/
|
||||
|
||||
import { base } from '$app/paths';
|
||||
import {
|
||||
API_MODELS,
|
||||
MODEL_ID,
|
||||
SSE_DATA_PREFIX,
|
||||
SSE_LINE_SEPARATOR,
|
||||
SSE_RECORD_SEPARATOR
|
||||
} from '$lib/constants';
|
||||
import { API_MODELS, MODEL_ID } from '$lib/constants';
|
||||
import { ServerModelStatus } from '$lib/enums';
|
||||
import type { ParsedModelId } from '$lib/types/models';
|
||||
import { apiFetch, apiPost, normalizeModelName } from '$lib/utils';
|
||||
import {
|
||||
apiFetch,
|
||||
apiPost,
|
||||
extractSseDataPayload,
|
||||
normalizeModelName,
|
||||
splitSseRecords
|
||||
} from '$lib/utils';
|
||||
import { getAuthHeaders } from '$lib/utils/api-headers';
|
||||
|
||||
export class ModelsService {
|
||||
private static readonly SSE_RECONNECT_MS = 1000;
|
||||
|
||||
/**
|
||||
* Check if a model is loaded based on its metadata.
|
||||
*
|
||||
* @param model - Model data entry from the API response
|
||||
* @returns True if the model status is LOADED
|
||||
*/
|
||||
static isModelLoaded(model: ApiModelDataEntry): boolean {
|
||||
return model.status.value === ServerModelStatus.LOADED;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Listing
|
||||
* Load/Unload
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if a model is currently loading.
|
||||
*
|
||||
* @param model - Model data entry from the API response
|
||||
* @returns True if the model status is LOADING
|
||||
*/
|
||||
static isModelLoading(model: ApiModelDataEntry): boolean {
|
||||
return model.status.value === ServerModelStatus.LOADING;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch list of models from OpenAI-compatible endpoint.
|
||||
* Works in both MODEL and ROUTER modes.
|
||||
@@ -41,14 +71,6 @@ export class ModelsService {
|
||||
return apiFetch<ApiRouterModelsListResponse>(API_MODELS.LIST);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Load/Unload
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Load a model (ROUTER mode only).
|
||||
* Sends POST request to `/models/load`. Note: the endpoint returns success
|
||||
@@ -68,137 +90,6 @@ export class ModelsService {
|
||||
return apiPost<ApiRouterModelsLoadResponse>(API_MODELS.LOAD, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload a model (ROUTER mode only).
|
||||
* Sends POST request to `/models/unload`. Note: the endpoint returns success
|
||||
* before unloading completes — use polling to await actual unload status.
|
||||
*
|
||||
* @param modelId - Model identifier to unload
|
||||
* @returns Unload response from the server
|
||||
*/
|
||||
static async unload(modelId: string): Promise<ApiRouterModelsUnloadResponse> {
|
||||
return apiPost<ApiRouterModelsUnloadResponse>(API_MODELS.UNLOAD, { model: modelId });
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Status
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if a model is loaded based on its metadata.
|
||||
*
|
||||
* @param model - Model data entry from the API response
|
||||
* @returns True if the model status is LOADED
|
||||
*/
|
||||
static isModelLoaded(model: ApiModelDataEntry): boolean {
|
||||
return model.status.value === ServerModelStatus.LOADED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model is currently loading.
|
||||
*
|
||||
* @param model - Model data entry from the API response
|
||||
* @returns True if the model status is LOADING
|
||||
*/
|
||||
static isModelLoading(model: ApiModelDataEntry): boolean {
|
||||
return model.status.value === ServerModelStatus.LOADING;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Status Feed
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
private static readonly SSE_RECONNECT_MS = 1000;
|
||||
|
||||
/**
|
||||
* Read the /models/sse feed and invoke onEvent for each parsed envelope.
|
||||
* Reconnects on network drops until the signal aborts. Splits the byte
|
||||
* stream into SSE records on the blank line boundary; the payload rides in
|
||||
* the data lines as a JSON envelope with its own model, event and data fields.
|
||||
*/
|
||||
static async watchModelEvents(
|
||||
signal: AbortSignal,
|
||||
onEvent: (event: ApiModelsSseEvent) => void
|
||||
): Promise<void> {
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
while (!signal.aborted) {
|
||||
try {
|
||||
const response = await fetch(`${base}${API_MODELS.SSE}`, {
|
||||
headers: getAuthHeaders(),
|
||||
signal
|
||||
});
|
||||
|
||||
if (response.ok && response.body) {
|
||||
const reader = response.body.getReader();
|
||||
|
||||
let buffer = '';
|
||||
|
||||
while (!signal.aborted) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
let boundary = buffer.indexOf(SSE_RECORD_SEPARATOR);
|
||||
|
||||
while (boundary !== -1) {
|
||||
const event = ModelsService.parseStatusRecord(buffer.slice(0, boundary));
|
||||
|
||||
if (event) onEvent(event);
|
||||
|
||||
buffer = buffer.slice(boundary + SSE_RECORD_SEPARATOR.length);
|
||||
boundary = buffer.indexOf(SSE_RECORD_SEPARATOR);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// network drop or abort falls through to the reconnect delay
|
||||
}
|
||||
|
||||
if (signal.aborted) return;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, ModelsService.SSE_RECONNECT_MS));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one SSE record into its JSON envelope, or null when the record
|
||||
* carries no data payload or malformed JSON.
|
||||
*/
|
||||
private static parseStatusRecord(record: string): ApiModelsSseEvent | null {
|
||||
const payload = record
|
||||
.split(SSE_LINE_SEPARATOR)
|
||||
.filter((line) => line.startsWith(SSE_DATA_PREFIX))
|
||||
.map((line) => line.slice(SSE_DATA_PREFIX.length).trim())
|
||||
.join(SSE_LINE_SEPARATOR);
|
||||
|
||||
if (payload.length === 0) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(payload) as ApiModelsSseEvent;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Parsing
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse a model ID string into its structured components.
|
||||
*
|
||||
@@ -311,4 +202,84 @@ export class ModelsService {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload a model (ROUTER mode only).
|
||||
* Sends POST request to `/models/unload`. Note: the endpoint returns success
|
||||
* before unloading completes — use polling to await actual unload status.
|
||||
*
|
||||
* @param modelId - Model identifier to unload
|
||||
* @returns Unload response from the server
|
||||
*/
|
||||
static async unload(modelId: string): Promise<ApiRouterModelsUnloadResponse> {
|
||||
return apiPost<ApiRouterModelsUnloadResponse>(API_MODELS.UNLOAD, { model: modelId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the /models/sse feed and invoke onEvent for each parsed envelope.
|
||||
* Reconnects on network drops until the signal aborts. Splits the byte
|
||||
* stream into SSE records on the blank line boundary; the payload rides in
|
||||
* the data lines as a JSON envelope with its own model, event and data fields.
|
||||
*/
|
||||
static async watchModelEvents(
|
||||
signal: AbortSignal,
|
||||
onEvent: (event: ApiModelsSseEvent) => void
|
||||
): Promise<void> {
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
while (!signal.aborted) {
|
||||
try {
|
||||
const response = await fetch(`${base}${API_MODELS.SSE}`, {
|
||||
headers: getAuthHeaders(),
|
||||
signal
|
||||
});
|
||||
|
||||
if (response.ok && response.body) {
|
||||
const reader = response.body.getReader();
|
||||
|
||||
let buffer = '';
|
||||
|
||||
while (!signal.aborted) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
const { records, rest } = splitSseRecords(buffer);
|
||||
|
||||
buffer = rest;
|
||||
|
||||
for (const record of records) {
|
||||
const event = ModelsService.parseStatusRecord(record);
|
||||
|
||||
if (event) onEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// network drop or abort falls through to the reconnect delay
|
||||
}
|
||||
|
||||
if (signal.aborted) return;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, ModelsService.SSE_RECONNECT_MS));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one SSE record into its JSON envelope, or null when the record
|
||||
* carries no data payload or malformed JSON.
|
||||
*/
|
||||
private static parseStatusRecord(record: string): ApiModelsSseEvent | null {
|
||||
const payload = extractSseDataPayload(record);
|
||||
|
||||
if (payload.length === 0) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(payload) as ApiModelsSseEvent;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
/**
|
||||
* ParameterSyncService - Syncs sampling parameters with the server
|
||||
*
|
||||
* Decides for each sampling parameter whether the user's setting is an
|
||||
* override of the server default, and normalizes floating-point values.
|
||||
* No reactive state; consumed by settingsStore.
|
||||
*/
|
||||
|
||||
import { SETTINGS_KEYS, SYNCABLE_PARAMETERS } from '$lib/constants';
|
||||
import { ParameterSource, SyncableParameterType } from '$lib/enums';
|
||||
import type { ParameterInfo, ParameterRecord, ParameterValue } from '$lib/types';
|
||||
@@ -5,22 +13,47 @@ import { normalizeFloatingPoint } from '$lib/utils';
|
||||
|
||||
export class ParameterSyncService {
|
||||
/**
|
||||
* Check if a parameter can be synced from server.
|
||||
*
|
||||
*
|
||||
* Extraction
|
||||
*
|
||||
*
|
||||
* @param key - The parameter key to check
|
||||
* @returns True if the parameter is in the syncable parameters list
|
||||
*/
|
||||
static canSyncParameter(key: string): boolean {
|
||||
return SYNCABLE_PARAMETERS.some((param) => param.key === key && param.canSync);
|
||||
}
|
||||
|
||||
/**
|
||||
* Round floating-point numbers to avoid JavaScript precision issues.
|
||||
* E.g., 0.1 + 0.2 = 0.30000000000000004 → 0.3
|
||||
* Create a diff between current settings and server defaults.
|
||||
* Shows which parameters differ from server values, useful for debugging
|
||||
* and for the "Reset to defaults" functionality.
|
||||
*
|
||||
* @param value - Parameter value to normalize
|
||||
* @returns Precision-normalized value
|
||||
* @param currentSettings - Current parameter values in the settings store
|
||||
* @param serverDefaults - Default values extracted from server props
|
||||
* @returns Record of parameter diffs with current value, server value, and whether they differ
|
||||
*/
|
||||
private static roundFloatingPoint(value: ParameterValue): ParameterValue {
|
||||
return normalizeFloatingPoint(value) as ParameterValue;
|
||||
static createParameterDiff(
|
||||
currentSettings: ParameterRecord,
|
||||
serverDefaults: ParameterRecord
|
||||
): Record<string, { current: ParameterValue; server: ParameterValue; differs: boolean }> {
|
||||
const diff: Record<
|
||||
string,
|
||||
{ current: ParameterValue; server: ParameterValue; differs: boolean }
|
||||
> = {};
|
||||
|
||||
for (const key of this.getSyncableParameterKeys()) {
|
||||
const currentValue = currentSettings[key];
|
||||
const serverValue = serverDefaults[key];
|
||||
|
||||
if (serverValue !== undefined) {
|
||||
diff[key] = {
|
||||
current: currentValue,
|
||||
differs: currentValue !== serverValue,
|
||||
server: serverValue
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return diff;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,49 +92,6 @@ export class ParameterSyncService {
|
||||
return extracted;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Merging
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Merge server defaults with current user settings.
|
||||
* User overrides always take priority — only parameters not in `userOverrides`
|
||||
* set will be updated from server defaults.
|
||||
*
|
||||
* @param currentSettings - Current parameter values in the settings store
|
||||
* @param serverDefaults - Default values extracted from server props
|
||||
* @param userOverrides - Set of parameter keys explicitly overridden by the user
|
||||
* @returns Merged parameter record with user overrides preserved
|
||||
*/
|
||||
static mergeWithServerDefaults(
|
||||
currentSettings: ParameterRecord,
|
||||
serverDefaults: ParameterRecord,
|
||||
userOverrides: Set<string> = new Set()
|
||||
): ParameterRecord {
|
||||
const merged = { ...currentSettings };
|
||||
|
||||
for (const [key, serverValue] of Object.entries(serverDefaults)) {
|
||||
// Only update if user hasn't explicitly overridden this parameter
|
||||
if (!userOverrides.has(key)) {
|
||||
merged[key] = this.roundFloatingPoint(serverValue);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Info
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get parameter information including source and values.
|
||||
* Used by SettingsChatParameterSourceIndicator to display the correct badge
|
||||
@@ -132,16 +122,6 @@ export class ParameterSyncService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a parameter can be synced from server.
|
||||
*
|
||||
* @param key - The parameter key to check
|
||||
* @returns True if the parameter is in the syncable parameters list
|
||||
*/
|
||||
static canSyncParameter(key: string): boolean {
|
||||
return SYNCABLE_PARAMETERS.some((param) => param.key === key && param.canSync);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all syncable parameter keys.
|
||||
*
|
||||
@@ -151,6 +131,33 @@ export class ParameterSyncService {
|
||||
return SYNCABLE_PARAMETERS.filter((param) => param.canSync).map((param) => param.key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge server defaults with current user settings.
|
||||
* User overrides always take priority — only parameters not in `userOverrides`
|
||||
* set will be updated from server defaults.
|
||||
*
|
||||
* @param currentSettings - Current parameter values in the settings store
|
||||
* @param serverDefaults - Default values extracted from server props
|
||||
* @param userOverrides - Set of parameter keys explicitly overridden by the user
|
||||
* @returns Merged parameter record with user overrides preserved
|
||||
*/
|
||||
static mergeWithServerDefaults(
|
||||
currentSettings: ParameterRecord,
|
||||
serverDefaults: ParameterRecord,
|
||||
userOverrides: Set<string> = new Set()
|
||||
): ParameterRecord {
|
||||
const merged = { ...currentSettings };
|
||||
|
||||
for (const [key, serverValue] of Object.entries(serverDefaults)) {
|
||||
// Only update if user hasn't explicitly overridden this parameter
|
||||
if (!userOverrides.has(key)) {
|
||||
merged[key] = this.roundFloatingPoint(serverValue);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a server parameter value against its expected type.
|
||||
*
|
||||
@@ -176,44 +183,13 @@ export class ParameterSyncService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Round floating-point numbers to avoid JavaScript precision issues.
|
||||
* E.g., 0.1 + 0.2 = 0.30000000000000004 → 0.3
|
||||
*
|
||||
*
|
||||
* Diff
|
||||
*
|
||||
*
|
||||
* @param value - Parameter value to normalize
|
||||
* @returns Precision-normalized value
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a diff between current settings and server defaults.
|
||||
* Shows which parameters differ from server values, useful for debugging
|
||||
* and for the "Reset to defaults" functionality.
|
||||
*
|
||||
* @param currentSettings - Current parameter values in the settings store
|
||||
* @param serverDefaults - Default values extracted from server props
|
||||
* @returns Record of parameter diffs with current value, server value, and whether they differ
|
||||
*/
|
||||
static createParameterDiff(
|
||||
currentSettings: ParameterRecord,
|
||||
serverDefaults: ParameterRecord
|
||||
): Record<string, { current: ParameterValue; server: ParameterValue; differs: boolean }> {
|
||||
const diff: Record<
|
||||
string,
|
||||
{ current: ParameterValue; server: ParameterValue; differs: boolean }
|
||||
> = {};
|
||||
|
||||
for (const key of this.getSyncableParameterKeys()) {
|
||||
const currentValue = currentSettings[key];
|
||||
const serverValue = serverDefaults[key];
|
||||
|
||||
if (serverValue !== undefined) {
|
||||
diff[key] = {
|
||||
current: currentValue,
|
||||
differs: currentValue !== serverValue,
|
||||
server: serverValue
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return diff;
|
||||
private static roundFloatingPoint(value: ParameterValue): ParameterValue {
|
||||
return normalizeFloatingPoint(value) as ParameterValue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* PropsService - Fetches server properties from /props
|
||||
*
|
||||
* Returns global server settings and capabilities, including per-model
|
||||
* modalities in MODEL mode. No reactive state; consumed by serverStore and
|
||||
* the model props manager.
|
||||
*/
|
||||
|
||||
import { apiFetchWithParams } from '$lib/utils';
|
||||
|
||||
export class PropsService {
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Fetching
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fetches global server properties from the `/props` endpoint.
|
||||
* In MODEL mode, returns modalities for the single loaded model.
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* ReadMediaService - Reads local media files for the read_media tool
|
||||
*
|
||||
* Encodes image and audio files as base64 data URLs with the metadata the
|
||||
* model needs. No reactive state; consumed by toolsStore.
|
||||
*/
|
||||
|
||||
import { ToolsService } from './tools.service';
|
||||
import {
|
||||
FILE_EXTENSION_SEPARATOR,
|
||||
@@ -40,7 +47,7 @@ function fileExtension(path: string): string {
|
||||
* actually use the result - the server has no idea which model is selected.
|
||||
*
|
||||
* @see buildReadMediaToolDefinition in constants/read-media.ts - tool schema sent to the LLM
|
||||
* @see agenticStore in stores/agentic.svelte.ts - tool dispatch and attachment extraction
|
||||
* @see agenticStore in stores/agentic/index.svelte.ts - tool dispatch and attachment extraction
|
||||
*/
|
||||
export class ReadMediaService {
|
||||
static async executeTool(
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* RouterService - Builds app route paths
|
||||
*
|
||||
* Returns chat and settings route strings from a single source of truth
|
||||
* (ROUTES). No state.
|
||||
*/
|
||||
|
||||
import { ROUTES } from '$lib/constants';
|
||||
|
||||
export class RouterService {
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* Sandbox harness - builds the srcdoc document for the sandboxed iframe
|
||||
*
|
||||
* Produces the HTML/CSP/worker shim that runs untrusted model code in an
|
||||
* opaque origin. Consumed by sandbox.service.
|
||||
*/
|
||||
|
||||
import WORKER_SHIM from './sandbox-worker.js?raw';
|
||||
import { NEWLINE } from '$lib/constants';
|
||||
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
/**
|
||||
* SandboxService - Runs untrusted code in a sandboxed worker
|
||||
*
|
||||
* Executes model-generated code inside a CSP-restricted, opaque-origin
|
||||
* iframe worker with output and timeout limits. No reactive state; consumed
|
||||
* by toolsStore for code-execution tools.
|
||||
*/
|
||||
|
||||
import { buildSandboxHarness } from './sandbox-harness';
|
||||
import {
|
||||
NEWLINE,
|
||||
@@ -8,7 +16,7 @@ import {
|
||||
SANDBOX_TOOL_NAME,
|
||||
SANDBOX_TRUNCATION_NOTICE
|
||||
} from '$lib/constants';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings/index.svelte';
|
||||
import type { ToolExecutionResult } from '$lib/types';
|
||||
|
||||
/** Cached harnesses keyed by whether nerdamer is included. */
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* ToolsService - Stateless server tools API layer
|
||||
*
|
||||
* Fetches the server's /tools listing and streams tool execution results.
|
||||
* No reactive state; consumed by toolsStore.
|
||||
*/
|
||||
|
||||
import { base } from '$app/paths';
|
||||
import { API_TOOLS, HEADERS } from '$lib/constants';
|
||||
import { ToolResponseField } from '$lib/enums';
|
||||
@@ -7,15 +14,6 @@ import { getJsonHeaders } from '$lib/utils/api-headers';
|
||||
import { parseSseJsonStream, type SseJsonEvent } from '$lib/utils/sse';
|
||||
|
||||
export class ToolsService {
|
||||
/**
|
||||
* Fetch the list of server tools from the server.
|
||||
*
|
||||
* @returns Array of tool definitions in OpenAI-compatible format
|
||||
*/
|
||||
static async list(): Promise<ServerToolInfo[]> {
|
||||
return apiFetch<ServerToolInfo[]>(API_TOOLS.LIST);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a server tool on the server.
|
||||
*
|
||||
@@ -76,6 +74,15 @@ export class ToolsService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the list of server tools from the server.
|
||||
*
|
||||
* @returns Array of tool definitions in OpenAI-compatible format
|
||||
*/
|
||||
static async list(): Promise<ServerToolInfo[]> {
|
||||
return apiFetch<ServerToolInfo[]>(API_TOOLS.LIST);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a server tool's output chunks from the server. The server
|
||||
* `POST /tools` endpoint with `{stream: true}` emits `data: {"chunk": "..."}`
|
||||
|
||||
Reference in New Issue
Block a user