char-fork/llama.cpp
git clone https://git.t4t.associates/char-fork/llama.cpp
521a64cd0
char/aliceai-t5-moe
1/** 2* ConversationTransferService - Stateless conversation import/export layer 3* 4* Owns the session file format (one JSONL record per line: a SESSION header 5* followed by MESSAGE records), ZIP archiving and browser downloads. 6* DB access and store refreshes stay in conversationsStore. 7*/ 8 9import { EXPORT_CONV , NEWLINE , ZIP_MAGIC } from '$lib/constants' ; 10import { 11FileExtensionText , 12MimeTypeApplication , 13MimeTypeText , 14SessionRecordType 15} from '$lib/enums' ; 16import { strFromU8 , strToU8 , unzipSync , zipSync } from 'fflate' ; 17 18export class ConversationTransferService { 19/** 20* Triggers a browser download of the provided exported conversation data 21* @param data - The exported conversation payload (a single conversation with its messages) 22* @param filename - Filename; if omitted, a deterministic name is generated 23*/ 24static downloadConversationFile ( data :ExportedConversation , filename ?:string ) :void { 25const { conv :conversation , messages :msgs } = data ; 26 27if ( ! conversation ) { 28console . error ( 'Invalid data: missing conversation' ); 29 30return ; 31} 32 33const downloadFilename = 34filename ?? ConversationTransferService . generateConversationFilename ( conversation , msgs ); 35const jsonl = ConversationTransferService . serializeSessionToJsonl ( data ); 36const blob = new Blob ([ jsonl ], { type :MimeTypeText . JSONL }); 37 38ConversationTransferService . triggerDownload ( blob , downloadFilename ); 39} 40 41/** 42* Triggers a browser download of multiple conversations as a `.zip`, one 43* `.jsonl` file per conversation. 44* @param data - The conversations to export 45*/ 46static downloadConversationsArchive ( data :ExportedConversation []) :void { 47if ( data . length === 0 ) { 48console . error ( 'Invalid data: no conversations to export' ); 49 50return ; 51} 52 53const usedNames = new Set < string >(); 54const files :Record < string , Uint8Array > = {}; 55 56for ( const session of data ) { 57const baseName = ConversationTransferService . generateConversationFilename ( 58session . conv , 59session . messages 60); 61 62// Disambiguate any duplicate filenames within the archive. 63let entryName = baseName ; 64let suffix = 1 ; 65 66while ( usedNames . has ( entryName )) { 67entryName = baseName . replace ( 68new RegExp ( ` ${ FileExtensionText . JSONL } $` ), 69`_ ${ suffix ++ } ${ FileExtensionText . JSONL } ` 70); 71} 72usedNames . add ( entryName ); 73 74files [ entryName ] = strToU8 ( ConversationTransferService . serializeSessionToJsonl ( session )); 75} 76 77const archiveName = ` ${ new Date (). toISOString (). split ( EXPORT_CONV . ISO_DATE_TIME_SEPARATOR )[ 0 ]} _conversations ${ FileExtensionText . ZIP } ` ; 78const zipped = zipSync ( files ); 79const blob = new Blob ([ zipped ], { type :MimeTypeApplication . ZIP }); 80 81ConversationTransferService . triggerDownload ( blob , archiveName ); 82} 83 84/** 85* Generates a sanitized filename for a conversation export 86* @param conversation - The conversation metadata 87* @param msgs - Optional array of messages belonging to the conversation 88* @returns The generated filename string 89*/ 90static generateConversationFilename ( 91conversation :{ id ?:string ; name ?:string }, 92msgs ?:DatabaseMessage [] 93) :string { 94const conversationName = ( conversation . name ?? '' ). trim (). toLowerCase (); 95const sanitizedName = conversationName 96. replace ( EXPORT_CONV . NON_ALPHANUMERIC_REGEX , EXPORT_CONV . NONALNUM_REPLACEMENT ) 97. replace ( EXPORT_CONV . MULTIPLE_UNDERSCORE_REGEX , '_' ) 98. substring ( 0 , EXPORT_CONV . NAME_SUFFIX_MAX_LENGTH ); 99// If we have messages, use the timestamp of the newest message 100const referenceDate = msgs ?. length 101 ?new Date ( Math . max ( ...msgs . map (( m ) => m . timestamp ))) 102 :new Date (); 103const iso = referenceDate . toISOString (). slice ( 0 , EXPORT_CONV . ISO_TIMESTAMP_SLICE ); 104const formattedDate = iso 105. replace ( EXPORT_CONV . ISO_DATE_TIME_SEPARATOR , EXPORT_CONV . ISO_DATE_TIME_SEPARATOR_REPLACEMENT ) 106. replaceAll ( EXPORT_CONV . ISO_TIME_SEPARATOR , EXPORT_CONV . ISO_TIME_SEPARATOR_REPLACEMENT ); 107const trimmedConvId = conversation . id ?. slice ( 0 , EXPORT_CONV . ID_TRIM_LENGTH ) ?? '' ; 108 109return ` ${ formattedDate } _conv_ ${ trimmedConvId } _ ${ sanitizedName } ${ FileExtensionText . JSONL } ` ; 110} 111 112/** 113* Parses an import file into conversations, accepting the current JSONL and 114* ZIP formats as well as the legacy JSON format. The format comes from the 115* contents, so an import works whatever the file is named. 116* @param file - The user-selected file 117* @returns The parsed conversations with their messages 118*/ 119static async parseImportFile ( file :File ) :Promise < ExportedConversation []> { 120const bytes = new Uint8Array ( await file . arrayBuffer ()); 121 122if ( ZIP_MAGIC . every (( byte , index ) => bytes [ index ] === byte )) { 123const entries = unzipSync ( bytes ); 124const sessions :ExportedConversation [] = []; 125 126for ( const [ entryName , entryBytes ] of Object . entries ( entries )) { 127if ( ! entryName . toLowerCase (). endsWith ( FileExtensionText . JSONL )) continue ; 128 129sessions . push ( ...ConversationTransferService . parseSessionsJsonl ( strFromU8 ( entryBytes ))); 130} 131 132return sessions ; 133} 134 135const text = strFromU8 ( bytes ); 136 137if ( ConversationTransferService . isSessionsJsonl ( text )) { 138return ConversationTransferService . parseSessionsJsonl ( text ); 139} 140 141// Legacy JSON format: an array of conversations or a single conversation object. 142const parsed = JSON . parse ( text ); 143 144if ( Array . isArray ( parsed )) { 145return parsed ; 146} 147 148if ( parsed && typeof parsed === 'object' && 'conv' in parsed && 'messages' in parsed ) { 149return [ parsed ]; 150} 151 152throw new Error ( 153'Invalid file format: expected array of conversations or single conversation object' 154); 155} 156 157/** 158* Parses the JSONL session format produced by {@link serializeSessionToJsonl}. 159* A `SessionRecordType.SESSION` line starts a new session; following 160* `SessionRecordType.MESSAGE` lines are appended to it. Supports multiple 161* sessions in a single file. 162* @param text - The JSONL file contents 163* @returns The parsed conversations with their messages 164*/ 165static parseSessionsJsonl ( text :string ) :ExportedConversation [] { 166const sessions :ExportedConversation [] = []; 167 168let current :ExportedConversation | null = null ; 169 170for ( const line of text . split ( NEWLINE )) { 171const trimmed = line . trim (); 172 173if ( ! trimmed ) continue ; 174 175const record = JSON . parse ( trimmed ); 176 177if ( record . type === SessionRecordType . SESSION ) { 178// Drop the discriminator and harness marker; the rest is the conversation. 179const conv = { ...record }; 180 181delete conv . type ; 182delete conv . harness ; 183current = { conv :conv as DatabaseConversation , messages :[] }; 184sessions . push ( current ); 185} else if ( record . type === SessionRecordType . MESSAGE ) { 186if ( ! current ) { 187throw new Error ( 'Invalid JSONL: message record before any session record' ); 188} 189 190const message = record . message as DatabaseMessage ; 191 192// `toolCalls` is parsed to an array on export; the DB stores it as a string. 193if ( message . toolCalls !== undefined && typeof message . toolCalls !== 'string' ) { 194message . toolCalls = JSON . stringify ( message . toolCalls ); 195} 196 197current . messages . push ( message ); 198} 199// Ignore unknown record types for forward compatibility. 200} 201 202return sessions ; 203} 204 205/** 206* Serializes a session (a conversation with its messages) as JSONL. 207* The first line is the session header (a `SessionRecordType.SESSION` record 208* carrying the conversation properties); each subsequent line is a single message. 209* @param data - The exported conversation payload 210* @returns The JSONL string (one record per line) 211*/ 212static serializeSessionToJsonl ( data :ExportedConversation ) :string { 213const { conv, messages} = data ; 214const sessionLine = JSON . stringify ({ 215harness :EXPORT_CONV . HARNESS , 216type :SessionRecordType . SESSION , 217 ...conv 218}); 219const messageLines = messages . map (( message :DatabaseMessage ) => { 220// `toolCalls` is stored as a JSON string; drop it when empty, otherwise parse it. 221const { toolCalls, ...rest } = message ; 222const normalized = toolCalls ?{ ...rest , toolCalls :JSON . parse ( toolCalls ) } :rest ; 223 224return JSON . stringify ({ message :normalized , type :SessionRecordType . MESSAGE }); 225}); 226 227return [ sessionLine , ...messageLines ]. join ( NEWLINE ); 228} 229 230/** 231* Reports whether the text is the JSONL session format, whose first non-empty 232* line is a `SessionRecordType.SESSION` record. A legacy JSON export starts 233* with an array or an object that has no such discriminator. 234* @param text - The file contents 235*/ 236private static isSessionsJsonl ( text :string ) :boolean { 237const trimmed = text . trimStart (); 238const lineEnd = trimmed . indexOf ( NEWLINE ); 239const firstLine = lineEnd === - 1 ?trimmed :trimmed . slice ( 0 , lineEnd ); 240 241try { 242return JSON . parse ( firstLine ). type === SessionRecordType . SESSION ; 243} catch { 244// Not a standalone JSON record, so not the JSONL format. 245return false ; 246} 247} 248 249/** 250* Triggers a browser download of a blob under the given filename. 251*/ 252private static triggerDownload ( blob :Blob , filename :string ) :void { 253const url = URL . createObjectURL ( blob ); 254const a = document . createElement ( 'a' ); 255 256a . href = url ; 257a . download = filename ; 258document . body . appendChild ( a ); 259a . click (); 260document . body . removeChild ( a ); 261URL . revokeObjectURL ( url ); 262} 263}