mirror of
https://github.com/LostRuins/koboldcpp.git
synced 2026-09-06 13:01:21 +02:00
ui: Services consolidation refactor (#27239)
* ui: Move stream lookup and replay fetches into ChatService chatStore called fetch() directly for /v1/streams/lookup and the /v1/stream replay. These now live next to the other stream-session methods in ChatService, so services stay the only API I/O layer. * ui: Move /models/sse feed reader into ModelsService ModelsService.watchModelEvents owns the byte stream, reconnect loop and SSE record parsing; modelsStore keeps only event routing and state. * ui: Extract conversation import/export into ConversationTransferService The JSONL session format, ZIP archiving and browser downloads are pure I/O with no store state, so they move out of conversationsStore. The store keeps the DB orchestration (bulkExportConversations, downloadConversation, importConversationsData) and delegates the format work. * ui: Consolidate active model resolution into modelsStore.activeModelId The same resolution chain was duplicated in useChatScreenActiveModel, ChatForm, ChatFormActionModels and contextStatsStore, with slight drift in the single-model fallback. The canonical getter now lives in modelsStore, and the shared last-assistant-model lookup moved to utils as getConversationModel. * ui: Initialize stores explicitly via initStores() Store constructors and module-level side effects ran migrations and localStorage reads in import order. Migrations rename and rewrite localStorage keys, so a settings load racing ahead of them could clobber migrated values. initStores() is called once from the root layout and runs migrations first, then the stores that read localStorage, then the conversations DB load. * refactor: Constants for stream query params
This commit is contained in:
committed by
GitHub
parent
fdf4c64604
commit
3dc7285b4f
@@ -48,6 +48,7 @@
|
||||
containsFileMentionLink,
|
||||
findCommandToken,
|
||||
findMentionToken,
|
||||
getConversationModel,
|
||||
isIMEComposing,
|
||||
isOffsetInCodeBlock,
|
||||
parseClipboardContent,
|
||||
@@ -190,31 +191,9 @@
|
||||
|
||||
let isRouter = $derived(serverStore.isRouterMode);
|
||||
let conversationModel = $derived(
|
||||
chatStore.getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
|
||||
getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
|
||||
);
|
||||
let activeModelId = $derived.by(() => {
|
||||
const options = modelsStore.models;
|
||||
|
||||
if (!isRouter) {
|
||||
return options.length > 0 ? options[0].model : null;
|
||||
}
|
||||
|
||||
const selectedId = modelsStore.selectedModelId;
|
||||
|
||||
if (selectedId) {
|
||||
const model = options.find((m) => m.id === selectedId);
|
||||
|
||||
if (model) return model.model;
|
||||
}
|
||||
|
||||
if (conversationModel) {
|
||||
const model = options.find((m) => m.model === conversationModel);
|
||||
|
||||
if (model) return model.model;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
let activeModelId = $derived(modelsStore.activeModelId);
|
||||
|
||||
let hasModelSelected = $derived(
|
||||
!isRouter || !!conversationModel || !!modelsStore.selectedModelId
|
||||
|
||||
+4
-31
@@ -1,12 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { ModelsSelectorDropdown, ModelsSelectorSheet } from '$lib/components/app';
|
||||
import {
|
||||
chatStore,
|
||||
conversationsStore,
|
||||
deviceStore,
|
||||
modelsStore,
|
||||
serverStore
|
||||
} from '$lib/stores';
|
||||
import { conversationsStore, deviceStore, modelsStore, serverStore } from '$lib/stores';
|
||||
import { getConversationModel } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
@@ -36,7 +31,7 @@
|
||||
let isOffline = $derived(!!serverStore.error);
|
||||
|
||||
let conversationModel = $derived(
|
||||
chatStore.getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
|
||||
getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
|
||||
);
|
||||
|
||||
let lastSyncedConversationModel: string | null = null;
|
||||
@@ -80,29 +75,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
let activeModelId = $derived.by(() => {
|
||||
const options = modelsStore.models;
|
||||
|
||||
if (!isRouter) {
|
||||
return options.length > 0 ? options[0].model : null;
|
||||
}
|
||||
|
||||
const selectedId = modelsStore.selectedModelId;
|
||||
|
||||
if (selectedId) {
|
||||
const model = options.find((m) => m.id === selectedId);
|
||||
|
||||
if (model) return model.model;
|
||||
}
|
||||
|
||||
if (conversationModel) {
|
||||
const model = options.find((m) => m.model === conversationModel);
|
||||
|
||||
if (model) return model.model;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
let activeModelId = $derived(modelsStore.activeModelId);
|
||||
|
||||
let modelPropsVersion = $state(0); // Used to trigger reactivity after fetch
|
||||
|
||||
|
||||
+4
-3
@@ -8,6 +8,7 @@
|
||||
} from '$lib/components/app';
|
||||
import SettingsGroup from '$lib/components/app/settings/SettingsGroup.svelte';
|
||||
import { ConversationSelectionMode, FileExtensionText, HtmlInputType } from '$lib/enums';
|
||||
import { ConversationTransferService } from '$lib/services';
|
||||
import { conversationsStore, settingsStore } from '$lib/stores';
|
||||
import { createMessageCountMap } from '$lib/utils';
|
||||
import { fade } from 'svelte/transition';
|
||||
@@ -147,9 +148,9 @@
|
||||
);
|
||||
|
||||
if (allData.length === 1) {
|
||||
conversationsStore.downloadConversationFile(allData[0]);
|
||||
ConversationTransferService.downloadConversationFile(allData[0]);
|
||||
} else {
|
||||
conversationsStore.downloadConversationsArchive(allData);
|
||||
ConversationTransferService.downloadConversationsArchive(allData);
|
||||
}
|
||||
|
||||
exportedConversations = selectedConversations;
|
||||
@@ -177,7 +178,7 @@
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const importedData = await conversationsStore.parseImportFile(file);
|
||||
const importedData = await ConversationTransferService.parseImportFile(file);
|
||||
|
||||
if (importedData.length === 0) {
|
||||
throw new Error('No conversations found in file');
|
||||
|
||||
@@ -31,5 +31,11 @@ export const API_STREAM = {
|
||||
LOOKUP: './v1/streams/lookup'
|
||||
};
|
||||
|
||||
// query params for the resumable stream routes
|
||||
export const STREAM_QUERY_PARAMS = {
|
||||
CONV_ID: 'conv_id',
|
||||
FROM: 'from'
|
||||
} as const;
|
||||
|
||||
/** CORS proxy endpoint path */
|
||||
export const CORS_PROXY_ENDPOINT = '/cors-proxy';
|
||||
|
||||
@@ -8,36 +8,15 @@
|
||||
* demand if they aren't cached yet.
|
||||
*/
|
||||
|
||||
import { chatStore, conversationsStore, modelsStore, serverStore } from '$lib/stores';
|
||||
import { conversationsStore, modelsStore, serverStore } from '$lib/stores';
|
||||
import { getConversationModel } from '$lib/utils';
|
||||
|
||||
export function useChatScreenActiveModel() {
|
||||
const isRouter = $derived(serverStore.isRouterMode);
|
||||
const conversationModel = $derived(
|
||||
chatStore.getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
|
||||
getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
|
||||
);
|
||||
const activeModelId = $derived.by(() => {
|
||||
const options = modelsStore.models;
|
||||
|
||||
if (!isRouter) {
|
||||
return options.length > 0 ? options[0].model : null;
|
||||
}
|
||||
|
||||
const selectedId = modelsStore.selectedModelId;
|
||||
|
||||
if (selectedId) {
|
||||
const model = options.find((m) => m.id === selectedId);
|
||||
|
||||
if (model) return model.model;
|
||||
}
|
||||
|
||||
if (conversationModel) {
|
||||
const model = options.find((m) => m.model === conversationModel);
|
||||
|
||||
if (model) return model.model;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
const activeModelId = $derived(modelsStore.activeModelId);
|
||||
|
||||
let modelPropsVersion = $state(0);
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { REASONING_EFFORT_LEVELS, REASONING_EFFORT_TOKENS } from '$lib/constants';
|
||||
import { ReasoningEffort } from '$lib/enums';
|
||||
import { chatStore, conversationsStore, modelsStore, serverStore } from '$lib/stores';
|
||||
import { conversationsStore, modelsStore, serverStore } from '$lib/stores';
|
||||
import type { ReasoningEffortLevel } from '$lib/types';
|
||||
import type { DatabaseMessage } from '$lib/types/database';
|
||||
import { getConversationModel } from '$lib/utils';
|
||||
|
||||
export interface UseReasoningMenuReturn {
|
||||
readonly modelSupportsThinking: boolean;
|
||||
@@ -24,7 +25,7 @@ export interface UseReasoningMenuReturn {
|
||||
*/
|
||||
export function useReasoningMenu(): UseReasoningMenuReturn {
|
||||
const conversationModel = $derived(
|
||||
chatStore.getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
|
||||
getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
|
||||
);
|
||||
// a router chat can carry reasoning from an earlier turn before the props
|
||||
// cache is primed, so a model that already produced thinking still qualifies
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SSE_DATA_PREFIX,
|
||||
SSE_DONE_MARKER,
|
||||
SSE_LINE_SEPARATOR,
|
||||
STREAM_QUERY_PARAMS,
|
||||
STREAM_RESUME_LOCALSTORAGE_KEY_PREFIX,
|
||||
STREAM_VISIBILITY_KICK_MS
|
||||
} from '$lib/constants';
|
||||
@@ -33,6 +34,7 @@ import type {
|
||||
ApiStreamSession
|
||||
} from '$lib/types/api';
|
||||
import { isAbortError } from '$lib/utils/abort';
|
||||
import { ApiError } from '$lib/utils/api-fetch';
|
||||
import { getAuthHeaders, getJsonHeaders } from '$lib/utils/api-headers';
|
||||
import { formatAttachmentText } from '$lib/utils/formatters';
|
||||
import { streamIdentity } from '$lib/utils/stream-identity';
|
||||
@@ -529,7 +531,7 @@ export class ChatService {
|
||||
try {
|
||||
const id = streamIdentity(conversationId, model);
|
||||
|
||||
await fetch(`${API_STREAM.BASE}?conv_id=${encodeURIComponent(id)}`, {
|
||||
await fetch(ChatService.buildStreamUrl(id), {
|
||||
headers: getAuthHeaders(),
|
||||
method: 'DELETE'
|
||||
});
|
||||
@@ -538,6 +540,46 @@ export class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up server-side stream sessions for the given conversation ids. Ids carry the frozen
|
||||
* conv::model identity when a model was bound at POST time.
|
||||
*/
|
||||
static async lookupStreamSessions(conversationIds: string[]): Promise<ApiStreamSession[]> {
|
||||
const resp = await fetch(API_STREAM.LOOKUP, {
|
||||
body: JSON.stringify({ conversation_ids: conversationIds }),
|
||||
headers: getJsonHeaders(),
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new ApiError(`Stream lookup failed with HTTP ${resp.status}`, resp.status);
|
||||
}
|
||||
|
||||
const body = (await resp.json()) as unknown;
|
||||
|
||||
if (!Array.isArray(body)) {
|
||||
throw new Error('Stream lookup returned a non-array response');
|
||||
}
|
||||
|
||||
return body as ApiStreamSession[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the full replay of a server-side stream from byte 0. Returns the raw Response so the
|
||||
* caller can pipe it through the SSE parser like a fresh stream.
|
||||
*/
|
||||
static async fetchStreamReplay(streamId: string): Promise<Response> {
|
||||
const resp = await fetch(ChatService.buildStreamUrl(streamId, 0), {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new ApiError(`Stream replay failed with HTTP ${resp.status}`, resp.status);
|
||||
}
|
||||
|
||||
return resp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the running session to splice into when discoverActiveStream lists candidates for a
|
||||
* conversation. Finalized sessions are not candidates: their final content was already written
|
||||
@@ -629,6 +671,15 @@ export class ChatService {
|
||||
return streamIdentity(conversationId, model);
|
||||
}
|
||||
|
||||
// build the replay route url for a stream identity, from is the resume byte offset, omitted
|
||||
// for the cancel route
|
||||
private static buildStreamUrl(streamId: string, from?: number): string {
|
||||
const query = `${STREAM_QUERY_PARAMS.CONV_ID}=${encodeURIComponent(streamId)}`;
|
||||
const offset = from === undefined ? '' : `&${STREAM_QUERY_PARAMS.FROM}=${from}`;
|
||||
|
||||
return `${API_STREAM.BASE}?${query}${offset}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect to an interrupted stream for this conversation. Returns the fetch Response so the
|
||||
* existing SSE parser drains it like a fresh stream. The server returns 200 on success, 404 if
|
||||
@@ -642,13 +693,10 @@ export class ChatService {
|
||||
const ac = new AbortController();
|
||||
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${API_STREAM.BASE}?conv_id=${encodeURIComponent(streamId)}&from=0`,
|
||||
{
|
||||
headers: getAuthHeaders(),
|
||||
signal: ac.signal
|
||||
}
|
||||
);
|
||||
const resp = await fetch(ChatService.buildStreamUrl(streamId, 0), {
|
||||
headers: getAuthHeaders(),
|
||||
signal: ac.signal
|
||||
});
|
||||
|
||||
ac.abort();
|
||||
|
||||
@@ -668,7 +716,7 @@ export class ChatService {
|
||||
const state = ChatService.getStreamState(conversationId);
|
||||
const from = state?.bytesReceived ?? 0;
|
||||
const id = streamIdentity(conversationId, model);
|
||||
const url = `${API_STREAM.BASE}?conv_id=${encodeURIComponent(id)}&from=${from}`;
|
||||
const url = ChatService.buildStreamUrl(id, from);
|
||||
|
||||
return await fetch(url, { headers: getAuthHeaders(), method: 'GET', signal });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* ConversationTransferService - Stateless conversation import/export layer
|
||||
*
|
||||
* Owns the session file format (one JSONL record per line: a SESSION header
|
||||
* followed by MESSAGE records), ZIP archiving and browser downloads.
|
||||
* DB access and store refreshes stay in conversationsStore.
|
||||
*/
|
||||
|
||||
import { EXPORT_CONV, NEWLINE, ZIP_MAGIC } from '$lib/constants';
|
||||
import {
|
||||
FileExtensionText,
|
||||
MimeTypeApplication,
|
||||
MimeTypeText,
|
||||
SessionRecordType
|
||||
} from '$lib/enums';
|
||||
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)
|
||||
* @param filename - Filename; if omitted, a deterministic name is generated
|
||||
*/
|
||||
static downloadConversationFile(data: ExportedConversation, filename?: string): void {
|
||||
const { conv: conversation, messages: msgs } = data;
|
||||
|
||||
if (!conversation) {
|
||||
console.error('Invalid data: missing conversation');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const downloadFilename =
|
||||
filename ?? ConversationTransferService.generateConversationFilename(conversation, msgs);
|
||||
const jsonl = ConversationTransferService.serializeSessionToJsonl(data);
|
||||
const blob = new Blob([jsonl], { type: MimeTypeText.JSONL });
|
||||
|
||||
ConversationTransferService.triggerDownload(blob, downloadFilename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a browser download of multiple conversations as a `.zip`, one
|
||||
* `.jsonl` file per conversation.
|
||||
* @param data - The conversations to export
|
||||
*/
|
||||
static downloadConversationsArchive(data: ExportedConversation[]): void {
|
||||
if (data.length === 0) {
|
||||
console.error('Invalid data: no conversations to export');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const usedNames = new Set<string>();
|
||||
const files: Record<string, Uint8Array> = {};
|
||||
|
||||
for (const session of data) {
|
||||
const baseName = ConversationTransferService.generateConversationFilename(
|
||||
session.conv,
|
||||
session.messages
|
||||
);
|
||||
|
||||
// Disambiguate any duplicate filenames within the archive.
|
||||
let entryName = baseName;
|
||||
let suffix = 1;
|
||||
|
||||
while (usedNames.has(entryName)) {
|
||||
entryName = baseName.replace(
|
||||
new RegExp(`${FileExtensionText.JSONL}$`),
|
||||
`_${suffix++}${FileExtensionText.JSONL}`
|
||||
);
|
||||
}
|
||||
usedNames.add(entryName);
|
||||
|
||||
files[entryName] = strToU8(ConversationTransferService.serializeSessionToJsonl(session));
|
||||
}
|
||||
|
||||
const archiveName = `${new Date().toISOString().split(EXPORT_CONV.ISO_DATE_TIME_SEPARATOR)[0]}_conversations${FileExtensionText.ZIP}`;
|
||||
const zipped = zipSync(files);
|
||||
const blob = new Blob([zipped], { type: MimeTypeApplication.ZIP });
|
||||
|
||||
ConversationTransferService.triggerDownload(blob, archiveName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a browser download of a blob under the given filename.
|
||||
*/
|
||||
private static triggerDownload(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,15 @@ export { ChatService } from './chat.service';
|
||||
*/
|
||||
export { DatabaseService } from './database.service';
|
||||
|
||||
/**
|
||||
* **ConversationTransferService** - Conversation import/export format layer
|
||||
*
|
||||
* Owns the JSONL session format (SESSION header + MESSAGE records), ZIP
|
||||
* archiving and browser downloads. Stateless; DB access and store refreshes
|
||||
* stay in conversationsStore.
|
||||
*/
|
||||
export { ConversationTransferService } from './conversation-transfer.service';
|
||||
|
||||
/**
|
||||
* **ModelsService** - Model management API communication
|
||||
*
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { API_MODELS, MODEL_ID } from '$lib/constants';
|
||||
import { base } from '$app/paths';
|
||||
import {
|
||||
API_MODELS,
|
||||
MODEL_ID,
|
||||
SSE_DATA_PREFIX,
|
||||
SSE_LINE_SEPARATOR,
|
||||
SSE_RECORD_SEPARATOR
|
||||
} from '$lib/constants';
|
||||
import { ServerModelStatus } from '$lib/enums';
|
||||
import type { ParsedModelId } from '$lib/types/models';
|
||||
import { apiFetch, apiPost, normalizeModelName } from '$lib/utils';
|
||||
import { getAuthHeaders } from '$lib/utils/api-headers';
|
||||
|
||||
export class ModelsService {
|
||||
/**
|
||||
@@ -100,6 +108,89 @@ export class ModelsService {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
import {
|
||||
CONVERSATION_ID_SEPARATOR,
|
||||
CWD_CLEARED_TEXT,
|
||||
HEADERS,
|
||||
INACTIVE_CONVERSATION,
|
||||
STREAM_RESUME_RETRY_MS,
|
||||
SYSTEM_MESSAGE_PLACEHOLDER,
|
||||
@@ -25,7 +24,6 @@ import {
|
||||
ErrorDialogType,
|
||||
MessageRole,
|
||||
MessageType,
|
||||
MimeTypeApplication,
|
||||
ReasoningEffort,
|
||||
StreamConnectionState
|
||||
} from '$lib/enums';
|
||||
@@ -58,7 +56,7 @@ import {
|
||||
findMessageById,
|
||||
formatCwdMessage,
|
||||
generateConversationTitle,
|
||||
getAuthHeaders,
|
||||
getConversationModel,
|
||||
isAbortError,
|
||||
normalizeModelName,
|
||||
streamIdentity
|
||||
@@ -222,33 +220,12 @@ class ChatStore {
|
||||
async probeServerStream(convId: string): Promise<ApiStreamSession | null> {
|
||||
if (!convId) return null;
|
||||
|
||||
let listResp: Response;
|
||||
|
||||
try {
|
||||
// POST the one conv id we are probing
|
||||
listResp = await fetch(`./v1/streams/lookup`, {
|
||||
body: JSON.stringify({ conversation_ids: [convId] }),
|
||||
headers: { ...getAuthHeaders(), [HEADERS.CONTENT_TYPE]: MimeTypeApplication.JSON },
|
||||
method: 'POST'
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('probeServerStream fetch failed:', e);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!listResp.ok) {
|
||||
console.warn(`probeServerStream got HTTP ${listResp.status} for conv ${convId}`);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
let sessions: ApiStreamSession[];
|
||||
|
||||
try {
|
||||
sessions = (await listResp.json()) as ApiStreamSession[];
|
||||
sessions = await ChatService.lookupStreamSessions([convId]);
|
||||
} catch (e) {
|
||||
console.warn('probeServerStream JSON parse failed:', e);
|
||||
console.warn(`probeServerStream failed for conv ${convId}:`, e);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -293,18 +270,9 @@ class ChatStore {
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(`./v1/stream?conv_id=${encodeURIComponent(id)}&from=0`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
response = await ChatService.fetchStreamReplay(id);
|
||||
} catch (e) {
|
||||
console.error('attachServerStream replay fetch failed:', e);
|
||||
unlock();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`attachServerStream replay got HTTP ${response.status} for conv ${convId}`);
|
||||
console.error(`attachServerStream replay failed for conv ${convId}:`, e);
|
||||
unlock();
|
||||
|
||||
return;
|
||||
@@ -804,21 +772,9 @@ class ChatStore {
|
||||
let sessions: ApiStreamSession[];
|
||||
|
||||
try {
|
||||
const resp = await fetch('./v1/streams/lookup', {
|
||||
body: JSON.stringify({ conversation_ids: lookupIds }),
|
||||
headers: { ...getAuthHeaders(), [HEADERS.CONTENT_TYPE]: MimeTypeApplication.JSON },
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!resp.ok) return;
|
||||
|
||||
const body = (await resp.json()) as unknown;
|
||||
|
||||
if (!Array.isArray(body)) return;
|
||||
|
||||
sessions = body as ApiStreamSession[];
|
||||
sessions = await ChatService.lookupStreamSessions(lookupIds);
|
||||
} catch (e) {
|
||||
console.warn('syncRemoteRunningStreams fetch failed:', e);
|
||||
console.warn('syncRemoteRunningStreams lookup failed:', e);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -1332,7 +1288,7 @@ class ChatStore {
|
||||
let effectiveModel: string | null | undefined = undefined;
|
||||
|
||||
if (serverStore.isRouterMode) {
|
||||
const conversationModel = this.getConversationModel(allMessages);
|
||||
const conversationModel = getConversationModel(allMessages);
|
||||
|
||||
effectiveModel = modelOverride || modelsStore.selectedModelName || conversationModel;
|
||||
}
|
||||
@@ -2785,16 +2741,6 @@ class ChatStore {
|
||||
}
|
||||
}
|
||||
|
||||
getConversationModel(messages: DatabaseMessage[]): string | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i];
|
||||
|
||||
if (message.role === MessageRole.ASSISTANT && message.model) return message.model;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private getApiOptions(): Record<string, unknown> {
|
||||
const currentConfig = settingsStore.config;
|
||||
const hasValue = (value: unknown): boolean =>
|
||||
|
||||
@@ -49,23 +49,8 @@ function deriveLiveStats(state: ApiProcessingState | null): LiveStats | null {
|
||||
}
|
||||
|
||||
class ContextStatsStore {
|
||||
// Resolve the model the stats report context for: explicit selection >
|
||||
// last assistant model > single-model mode (mirrors useChatScreenActiveModel).
|
||||
activeModelId = $derived.by(() => {
|
||||
if (!serverStore.isRouterMode) {
|
||||
return modelsStore.singleModelName;
|
||||
}
|
||||
|
||||
const selectedId = modelsStore.selectedModelId;
|
||||
|
||||
if (selectedId) {
|
||||
const model = modelsStore.models.find((m) => m.id === selectedId);
|
||||
|
||||
if (model) return model.model;
|
||||
}
|
||||
|
||||
return chatStore.getConversationModel(conversationsStore.activeMessages as DatabaseMessage[]);
|
||||
});
|
||||
// The canonical resolution lives in modelsStore.activeModelId.
|
||||
activeModelId = $derived(modelsStore.activeModelId);
|
||||
|
||||
isActiveModelLoaded = $derived(
|
||||
this.activeModelId !== null &&
|
||||
|
||||
@@ -20,21 +20,9 @@
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import {
|
||||
EXPORT_CONV,
|
||||
NEWLINE,
|
||||
REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY,
|
||||
ROUTES,
|
||||
ZIP_MAGIC
|
||||
} from '$lib/constants';
|
||||
import {
|
||||
FileExtensionText,
|
||||
MessageRole,
|
||||
MimeTypeApplication,
|
||||
MimeTypeText,
|
||||
ReasoningEffort,
|
||||
SessionRecordType
|
||||
} from '$lib/enums';
|
||||
import { REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY, ROUTES } from '$lib/constants';
|
||||
import { MessageRole, ReasoningEffort } from '$lib/enums';
|
||||
import { ConversationTransferService } from '$lib/services/conversation-transfer.service';
|
||||
import { DatabaseService } from '$lib/services/database.service';
|
||||
import { MigrationService } from '$lib/services/migration.service';
|
||||
import { RouterService } from '$lib/services/router.service';
|
||||
@@ -43,7 +31,6 @@ import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import type { McpServerOverride } from '$lib/types/database';
|
||||
import { filterByLeafNodeId, findLeafNode, generateConversationTitle } from '$lib/utils';
|
||||
import { strFromU8, strToU8, unzipSync, zipSync } from 'fflate';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
@@ -479,7 +466,7 @@ class ConversationsStore {
|
||||
return;
|
||||
}
|
||||
|
||||
this.downloadConversationsArchive(exported);
|
||||
ConversationTransferService.downloadConversationsArchive(exported);
|
||||
|
||||
toast.success(
|
||||
exported.length === 1
|
||||
@@ -951,247 +938,6 @@ class ConversationsStore {
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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 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
|
||||
*/
|
||||
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(...this.parseSessionsJsonl(strFromU8(entryBytes)));
|
||||
}
|
||||
|
||||
return sessions;
|
||||
}
|
||||
|
||||
const text = strFromU8(bytes);
|
||||
|
||||
if (this.isSessionsJsonl(text)) {
|
||||
return this.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'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a browser download of the provided exported conversation data
|
||||
* @param data - The exported conversation payload (a single conversation with its messages)
|
||||
* @param filename - Filename; if omitted, a deterministic name is generated
|
||||
*/
|
||||
downloadConversationFile(data: ExportedConversation, filename?: string): void {
|
||||
const { conv: conversation, messages: msgs } = data;
|
||||
|
||||
if (!conversation) {
|
||||
console.error('Invalid data: missing conversation');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const downloadFilename = filename ?? this.generateConversationFilename(conversation, msgs);
|
||||
const jsonl = this.serializeSessionToJsonl(data);
|
||||
const blob = new Blob([jsonl], { type: MimeTypeText.JSONL });
|
||||
|
||||
this.triggerDownload(blob, downloadFilename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a browser download of multiple conversations as a `.zip`, one
|
||||
* `.jsonl` file per conversation.
|
||||
* @param data - The conversations to export
|
||||
*/
|
||||
downloadConversationsArchive(data: ExportedConversation[]): void {
|
||||
if (data.length === 0) {
|
||||
console.error('Invalid data: no conversations to export');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const usedNames = new SvelteSet<string>();
|
||||
const files: Record<string, Uint8Array> = {};
|
||||
|
||||
for (const session of data) {
|
||||
const baseName = this.generateConversationFilename(session.conv, session.messages);
|
||||
|
||||
// Disambiguate any duplicate filenames within the archive.
|
||||
let entryName = baseName;
|
||||
let suffix = 1;
|
||||
|
||||
while (usedNames.has(entryName)) {
|
||||
entryName = baseName.replace(
|
||||
new RegExp(`${FileExtensionText.JSONL}$`),
|
||||
`_${suffix++}${FileExtensionText.JSONL}`
|
||||
);
|
||||
}
|
||||
usedNames.add(entryName);
|
||||
|
||||
files[entryName] = strToU8(this.serializeSessionToJsonl(session));
|
||||
}
|
||||
|
||||
const archiveName = `${new Date().toISOString().split(EXPORT_CONV.ISO_DATE_TIME_SEPARATOR)[0]}_conversations${FileExtensionText.ZIP}`;
|
||||
const zipped = zipSync(files);
|
||||
const blob = new Blob([zipped], { type: MimeTypeApplication.ZIP });
|
||||
|
||||
this.triggerDownload(blob, archiveName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a browser download of a blob under the given filename.
|
||||
*/
|
||||
private triggerDownload(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a single conversation as a JSONL file, serializing the full message tree.
|
||||
* @param convId - The conversation ID to download
|
||||
@@ -1206,7 +952,7 @@ class ConversationsStore {
|
||||
|
||||
const messages = await DatabaseService.getConversationMessages(convId);
|
||||
|
||||
this.downloadConversationFile({ conv: conversation, messages });
|
||||
ConversationTransferService.downloadConversationFile({ conv: conversation, messages });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1226,8 +972,3 @@ class ConversationsStore {
|
||||
}
|
||||
|
||||
export const conversationsStore = new ConversationsStore();
|
||||
|
||||
// Auto-initialize in browser
|
||||
if (browser) {
|
||||
conversationsStore.init();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Explicit store initialization, called once from the root layout.
|
||||
*
|
||||
* Order matters: migrations run first because they rename and rewrite
|
||||
* localStorage keys, so every store that reads localStorage initializes
|
||||
* only after they complete. Constructors and module-level side effects
|
||||
* stay empty so import order can no longer change startup behavior.
|
||||
*/
|
||||
|
||||
// direct imports, not via the barrel, to avoid circular deps
|
||||
import { conversationsStore } from './conversations.svelte';
|
||||
import { permissionsStore } from './permissions.svelte';
|
||||
import { settingsStore } from './settings.svelte';
|
||||
import { toolsStore } from './tools.svelte';
|
||||
import { versionStore } from './version.svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { MigrationService } from '$lib/services/migration.service';
|
||||
|
||||
let started = false;
|
||||
|
||||
export async function initStores(): Promise<void> {
|
||||
if (!browser || started) return;
|
||||
|
||||
started = true;
|
||||
|
||||
await MigrationService.runAllMigrations();
|
||||
|
||||
settingsStore.initialize();
|
||||
permissionsStore.initialize();
|
||||
toolsStore.initialize();
|
||||
void versionStore.initialize();
|
||||
|
||||
await conversationsStore.init();
|
||||
}
|
||||
@@ -1,12 +1,4 @@
|
||||
import { base } from '$app/paths';
|
||||
import {
|
||||
API_MODELS,
|
||||
FAVORITE_MODELS_LOCALSTORAGE_KEY,
|
||||
MODEL_PROPS_CACHE,
|
||||
SSE_DATA_PREFIX,
|
||||
SSE_LINE_SEPARATOR,
|
||||
SSE_RECORD_SEPARATOR
|
||||
} from '$lib/constants';
|
||||
import { FAVORITE_MODELS_LOCALSTORAGE_KEY, MODEL_PROPS_CACHE } from '$lib/constants';
|
||||
import {
|
||||
FileTypeCategory,
|
||||
ModelModality,
|
||||
@@ -20,12 +12,12 @@ import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
// deep imports, not the '$lib/utils' barrel: it re-exports modules that reach back
|
||||
// into the stores, and going through it here would read a half-built module
|
||||
import { getAuthHeaders } from '$lib/utils/api-headers';
|
||||
import { TTLCache } from '$lib/utils/cache-ttl';
|
||||
import {
|
||||
detectThinkingSupport,
|
||||
detectThinkingSupportWithReason
|
||||
} from '$lib/utils/chat-template-thinking-detector';
|
||||
import { getConversationModel } from '$lib/utils/conversation-utils';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
@@ -141,6 +133,33 @@ class ModelsStore {
|
||||
return props.model_path.split(/(\\|\/)/).pop() || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model the active conversation view resolves to. Router mode: the user's
|
||||
* selection first, then the conversation's own model. Otherwise the single
|
||||
* served model, from the models list or the server props as a fallback.
|
||||
*/
|
||||
get activeModelId(): string | null {
|
||||
if (!serverStore.isRouterMode) {
|
||||
return this.models.length > 0 ? this.models[0].model : this.singleModelName;
|
||||
}
|
||||
|
||||
if (this.selectedModelId) {
|
||||
const selected = this.models.find((m) => m.id === this.selectedModelId);
|
||||
|
||||
if (selected) return selected.model;
|
||||
}
|
||||
|
||||
const conversationModel = getConversationModel(conversationsStore.activeMessages);
|
||||
|
||||
if (conversationModel) {
|
||||
const model = this.models.find((m) => m.model === conversationModel);
|
||||
|
||||
if (model) return model.model;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
get selectedModelContextSize(): number | null {
|
||||
if (!this.selectedModelName) return null;
|
||||
|
||||
@@ -717,8 +736,6 @@ class ModelsStore {
|
||||
*/
|
||||
|
||||
// reconnect delay after the feed drops or the server is not ready yet
|
||||
private static readonly SSE_RECONNECT_MS = 1000;
|
||||
|
||||
/**
|
||||
* Open the /models/sse feed and keep it live with auto reconnect.
|
||||
* Idempotent and router mode only. The feed drives status and progress,
|
||||
@@ -752,72 +769,10 @@ class ModelsStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the feed and reconnect until unsubscribed. Splits the byte stream
|
||||
* into SSE records on the blank line boundary.
|
||||
* Read the feed and reconnect until unsubscribed.
|
||||
*/
|
||||
private async runStatusReader(signal: AbortSignal): 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) {
|
||||
this.handleStatusRecord(buffer.slice(0, boundary));
|
||||
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, ModelsStore.SSE_RECONNECT_MS));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one SSE record. The payload rides in the data lines as a JSON
|
||||
* envelope that carries its own model, event and data fields.
|
||||
*/
|
||||
private handleStatusRecord(record: string): void {
|
||||
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;
|
||||
|
||||
let envelope: ApiModelsSseEvent;
|
||||
|
||||
try {
|
||||
envelope = JSON.parse(payload);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
this.applyStatusEvent(envelope);
|
||||
await ModelsService.watchModelEvents(signal, (event) => this.applyStatusEvent(event));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,11 @@ import { SvelteSet } from 'svelte/reactivity';
|
||||
class PermissionsStore {
|
||||
private _tools = $state(new SvelteSet<string>());
|
||||
|
||||
constructor() {
|
||||
/**
|
||||
* Load persisted permissions. Called by initStores() after migrations
|
||||
* have run.
|
||||
*/
|
||||
initialize(): void {
|
||||
// browser-only init: skip on SSR to avoid localStorage side effects
|
||||
if (!browser) return;
|
||||
|
||||
|
||||
@@ -85,12 +85,6 @@ class SettingsStore {
|
||||
return ParameterSyncService.extractServerDefaults(serverStore.defaultParams);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
if (browser) {
|
||||
this.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
@@ -100,9 +94,12 @@ class SettingsStore {
|
||||
*/
|
||||
|
||||
/**
|
||||
* Initialize the settings store by loading from localStorage
|
||||
* Initialize the settings store by loading from localStorage.
|
||||
* Called by initStores() after migrations have run.
|
||||
*/
|
||||
initialize() {
|
||||
if (!browser) return;
|
||||
|
||||
try {
|
||||
this.loadConfig();
|
||||
this.migrateLegacyTheme();
|
||||
|
||||
@@ -38,7 +38,11 @@ class ToolsStore {
|
||||
private _toolsEndpointUnreachable = $state(false);
|
||||
private _serverHome = $state<string | null | undefined>(undefined);
|
||||
|
||||
constructor() {
|
||||
/**
|
||||
* Load persisted disabled tools and fetch the builtin tool list.
|
||||
* Called by initStores() after migrations have run.
|
||||
*/
|
||||
initialize(): void {
|
||||
// browser-only init: skip on SSR to avoid localStorage/fetch side effects
|
||||
if (!browser) return;
|
||||
|
||||
|
||||
@@ -18,7 +18,11 @@ class VersionStore {
|
||||
build = $state<string>('');
|
||||
frontend = $state<string>('');
|
||||
|
||||
constructor() {
|
||||
/**
|
||||
* Fetch the version files. Called by initStores(); order-independent,
|
||||
* so it runs in the background.
|
||||
*/
|
||||
initialize(): void {
|
||||
if (!browser) return;
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
/**
|
||||
* Utility functions for conversation data manipulation
|
||||
*/
|
||||
import { MessageRole } from '$lib/enums';
|
||||
import type { DatabaseMessage } from '$lib/types';
|
||||
|
||||
/**
|
||||
* Model that generated the conversation's latest assistant message, or null
|
||||
* when no assistant message carries one.
|
||||
*/
|
||||
export function getConversationModel(messages: readonly DatabaseMessage[]): string | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i];
|
||||
|
||||
if (message.role === MessageRole.ASSISTANT && message.model) return message.model;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a map of conversation IDs to their message counts from exported conversation data
|
||||
* @param exportedData - Array of exported conversations with their messages
|
||||
|
||||
@@ -54,6 +54,7 @@ export { modelLoadFraction, modelLoadProgressText } from './progress';
|
||||
export {
|
||||
createMessageCountMap,
|
||||
getMessageCount,
|
||||
getConversationModel,
|
||||
buildConversationTree,
|
||||
type ConversationTreeItem
|
||||
} from './conversation-utils';
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
settingsStore,
|
||||
versionStore
|
||||
} from '$lib/stores';
|
||||
import { initStores } from '$lib/stores/init';
|
||||
import { ModeWatcher } from 'mode-watcher';
|
||||
import { untrack } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
@@ -35,6 +36,10 @@
|
||||
import { pwaAssetsHead } from 'virtual:pwa-assets/head';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// migrations and store startup, ordered explicitly instead of import side effects
|
||||
void initStores();
|
||||
|
||||
let innerHeight = $state<number | undefined>();
|
||||
let innerWidth = $state(browser ? window.innerWidth : 0);
|
||||
|
||||
|
||||
@@ -1,35 +1,9 @@
|
||||
import { NEWLINE } from '$lib/constants';
|
||||
import { MessageRole, MessageType } from '$lib/enums';
|
||||
import { ConversationTransferService } from '$lib/services/conversation-transfer.service';
|
||||
import type { ExportedConversation } from '$lib/types/database';
|
||||
import { strToU8, zipSync } from 'fflate';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
let conversationsStore: typeof import('$lib/stores/conversations.svelte').conversationsStore;
|
||||
|
||||
// node env unit project has no DOM, install a minimal localStorage backed by a
|
||||
// Map before the store module reads it. Transforming the store takes seconds,
|
||||
// so import it once for the whole file.
|
||||
beforeAll(async () => {
|
||||
const store = new Map<string, string>();
|
||||
const polyfill: Storage = {
|
||||
clear: () => store.clear(),
|
||||
getItem: (k) => (store.has(k) ? store.get(k)! : null),
|
||||
key: (i) => Array.from(store.keys())[i] ?? null,
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
removeItem: (k) => {
|
||||
store.delete(k);
|
||||
},
|
||||
setItem: (k, v) => {
|
||||
store.set(k, String(v));
|
||||
}
|
||||
};
|
||||
|
||||
(globalThis as unknown as { localStorage: Storage }).localStorage = polyfill;
|
||||
|
||||
({ conversationsStore } = await import('$lib/stores/conversations.svelte'));
|
||||
}, 30000);
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function makeSession(id: string): ExportedConversation {
|
||||
return {
|
||||
@@ -54,10 +28,10 @@ function makeSession(id: string): ExportedConversation {
|
||||
* for `.jsonl`, so the picker cannot filter on it and the filename carries no
|
||||
* guarantee: a JSONL export must import under any name.
|
||||
*/
|
||||
describe('conversationsStore.parseImportFile', () => {
|
||||
describe('ConversationTransferService.parseImportFile', () => {
|
||||
it('imports a JSONL export whose name has no meaningful extension', async () => {
|
||||
const jsonl = conversationsStore.serializeSessionToJsonl(makeSession('a'));
|
||||
const sessions = await conversationsStore.parseImportFile(new File([jsonl], 'export'));
|
||||
const jsonl = ConversationTransferService.serializeSessionToJsonl(makeSession('a'));
|
||||
const sessions = await ConversationTransferService.parseImportFile(new File([jsonl], 'export'));
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].conv.id).toBe('a');
|
||||
@@ -66,27 +40,33 @@ describe('conversationsStore.parseImportFile', () => {
|
||||
|
||||
it('imports several sessions from one JSONL file', async () => {
|
||||
const jsonl = [makeSession('a'), makeSession('b')]
|
||||
.map((session) => conversationsStore.serializeSessionToJsonl(session))
|
||||
.map((session) => ConversationTransferService.serializeSessionToJsonl(session))
|
||||
.join(NEWLINE);
|
||||
const sessions = await conversationsStore.parseImportFile(new File([jsonl], 'export.txt'));
|
||||
const sessions = await ConversationTransferService.parseImportFile(
|
||||
new File([jsonl], 'export.txt')
|
||||
);
|
||||
|
||||
expect(sessions.map((session) => session.conv.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('imports a ZIP archive whose name has no meaningful extension', async () => {
|
||||
const zipped = zipSync({
|
||||
'a.jsonl': strToU8(conversationsStore.serializeSessionToJsonl(makeSession('a'))),
|
||||
'b.jsonl': strToU8(conversationsStore.serializeSessionToJsonl(makeSession('b'))),
|
||||
'a.jsonl': strToU8(ConversationTransferService.serializeSessionToJsonl(makeSession('a'))),
|
||||
'b.jsonl': strToU8(ConversationTransferService.serializeSessionToJsonl(makeSession('b'))),
|
||||
'notes.txt': strToU8('ignored')
|
||||
});
|
||||
const sessions = await conversationsStore.parseImportFile(new File([zipped], 'archive'));
|
||||
const sessions = await ConversationTransferService.parseImportFile(
|
||||
new File([zipped], 'archive')
|
||||
);
|
||||
|
||||
expect(sessions.map((session) => session.conv.id).sort()).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('imports the legacy JSON array format', async () => {
|
||||
const json = JSON.stringify([makeSession('a')], null, 2);
|
||||
const sessions = await conversationsStore.parseImportFile(new File([json], 'export.jsonl'));
|
||||
const sessions = await ConversationTransferService.parseImportFile(
|
||||
new File([json], 'export.jsonl')
|
||||
);
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].conv.id).toBe('a');
|
||||
@@ -94,7 +74,7 @@ describe('conversationsStore.parseImportFile', () => {
|
||||
|
||||
it('imports the legacy JSON single object format', async () => {
|
||||
const json = JSON.stringify(makeSession('a'));
|
||||
const sessions = await conversationsStore.parseImportFile(new File([json], 'export'));
|
||||
const sessions = await ConversationTransferService.parseImportFile(new File([json], 'export'));
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].conv.id).toBe('a');
|
||||
@@ -102,7 +82,7 @@ describe('conversationsStore.parseImportFile', () => {
|
||||
|
||||
it('rejects a file that holds neither format', async () => {
|
||||
await expect(
|
||||
conversationsStore.parseImportFile(new File(['not an export'], 'export.jsonl'))
|
||||
ConversationTransferService.parseImportFile(new File(['not an export'], 'export.jsonl'))
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user