char-fork/llama.cpp
git clone https://git.t4t.associates/char-fork/llama.cpp
fe235f434
char/aliceai-t5-moe
1/** 2* MigrationService - Unified data migration hook 3* 4* Centralizes all data migrations (localStorage, IndexedDB, legacy formats) 5* into a single initialization point. Each migration copies data to the new 6* format WITHOUT deleting the old, and state is tracked in localStorage so 7* `runAllMigrations()` (called once at startup) never re-runs a completed 8* migration. All migrations are non-destructive for downgrade compatibility. 9*/ 10 11import { 12CONFIG_LOCALSTORAGE_KEY , 13DB_APP_NAME_DEPRECATED , 14DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY , 15IDXDB_STORES , 16IDXDB_TABLES , 17LEGACY_AGENTIC_REGEX , 18LEGACY_REASONING_TAGS , 19NEW_TO_DEPRECATED_MAP , 20SETTINGS_KEYS , 21STORAGE_APP_NAME , 22STORAGE_APP_NAME_DEPRECATED 23} from '$lib/constants' ; 24import { BooleanString , MessageRole } from '$lib/enums' ; 25import type { McpServerOverride } from '$lib/types/database' ; 26import Dexie from 'dexie' ; 27 28// Types 29 30interface Migration { 31/** Unique identifier for this migration */ 32id :string ; 33/** Human-readable description */ 34description :string ; 35/** Run the migration forward (non-destructive - copies, doesn't delete) */ 36run () :Promise < void >; 37} 38 39interface MigrationState { 40completed :string []; 41failed :string []; 42lastRun :string ; 43} 44 45// Constants 46 47const MIGRATION_STATE_KEY = ` ${ STORAGE_APP_NAME } .migration-state` ; 48const MIGRATION_STATE_VERSION = 1 ; 49 50// State Management 51 52function getMigrationState () :MigrationState { 53try { 54const raw = localStorage . getItem ( MIGRATION_STATE_KEY ); 55 56if ( ! raw ) return { completed :[], failed :[], lastRun :'' }; 57 58const parsed = JSON . parse ( raw ); 59 60if ( parsed . version !== MIGRATION_STATE_VERSION ) { 61return { completed :[], failed :[], lastRun :'' }; 62} 63 64return { 65completed :parsed . completed ?? [], 66failed :parsed . failed ?? [], 67lastRun :parsed . lastRun ?? '' 68}; 69} catch { 70return { completed :[], failed :[], lastRun :'' }; 71} 72} 73 74function saveMigrationState ( state :MigrationState ) :void { 75localStorage . setItem ( 76MIGRATION_STATE_KEY , 77JSON . stringify ({ 78version :MIGRATION_STATE_VERSION , 79 ...state , 80lastRun :new Date (). toISOString () 81}) 82); 83} 84 85function isMigrationCompleted ( id :string ) :boolean { 86const state = getMigrationState (); 87 88return state . completed . includes ( id ); 89} 90 91function markMigrationCompleted ( id :string ) :void { 92const state = getMigrationState (); 93 94if ( ! state . completed . includes ( id )) { 95state . completed . push ( id ); 96} 97 98state . failed = state . failed . filter (( f ) => f !== id ); 99saveMigrationState ( state ); 100} 101 102function markMigrationFailed ( id :string ) :void { 103const state = getMigrationState (); 104 105if ( ! state . failed . includes ( id )) { 106state . failed . push ( id ); 107} 108 109saveMigrationState ( state ); 110} 111 112// Migration 1: LocalStorage Key Prefix (Non-Destructive) 113 114const LOCALSTORAGE_MIGRATION_ID = 'localstorage-prefix-v1' ; 115const localStorageMigration :Migration = { 116description :'Copy localStorage keys from LlamaCppWebui to LlamaUi prefix (non-destructive)' , 117id :LOCALSTORAGE_MIGRATION_ID , 118 119async run () :Promise < void > { 120// Non-destructive: copy to new key, but KEEP the old key 121for ( const [ newKey , deprecatedKey ] of Object . entries ( NEW_TO_DEPRECATED_MAP )) { 122// Only migrate if new key doesn't already exist 123const newValue = localStorage . getItem ( newKey ); 124 125if ( newValue !== null ) { 126if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) 127console . log ( `[Migration] localStorage: ${ newKey } already exists, skipping` ); 128 129continue ; 130} 131 132const oldValue = localStorage . getItem ( deprecatedKey ); 133 134if ( oldValue !== null ) { 135localStorage . setItem ( newKey , oldValue ); 136 137// Keep old key for downgrade compatibility - DO NOT DELETE 138if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) { 139console . log ( 140`[Migration] localStorage: copied ${ deprecatedKey } → ${ newKey } (preserved old)` 141); 142} 143} 144} 145} 146}; 147 148// Migration 2: IndexedDB Database Name (Non-Destructive) 149 150// eslint-disable-next-line padding-line-between-statements -- comment header separates this const group 151const IDXDB_MIGRATION_ID = 'idxdb-database-v1' ; 152const idxdbMigration :Migration = { 153description :'Copy IndexedDB from LlamacppWebui to LlamaUi database (non-destructive)' , 154id :IDXDB_MIGRATION_ID , 155 156async run () :Promise < void > { 157const oldDbNames = await Dexie . getDatabaseNames (); 158 159if ( ! oldDbNames . includes ( DB_APP_NAME_DEPRECATED )) { 160if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) 161console . log ( '[Migration] IndexedDB: no old database found, skipping' ); 162 163return ; 164} 165 166// Check if new database already has data 167const newDb = new Dexie ( STORAGE_APP_NAME ); 168 169newDb . version ( 1 ). stores ( IDXDB_STORES ); 170const existingConvs = await newDb . table ( IDXDB_TABLES . conversations ). count (); 171 172if ( existingConvs > 0 ) { 173if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) 174console . log ( '[Migration] IndexedDB: new database already has data, skipping' ); 175 176return ; 177} 178 179if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) 180console . log ( '[Migration] IndexedDB: copying from' , DB_APP_NAME_DEPRECATED ); 181 182const oldDb = new Dexie ( DB_APP_NAME_DEPRECATED ); 183 184oldDb . version ( 1 ). stores ( IDXDB_STORES ); 185 186const conversations = await oldDb . table ( IDXDB_TABLES . conversations ). toArray (); 187const messages = await oldDb . table ( IDXDB_TABLES . messages ). toArray (); 188 189if ( conversations . length > 0 ) { 190await newDb . table ( IDXDB_TABLES . conversations ). bulkAdd ( conversations ); 191 192if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) 193console . log ( `[Migration] IndexedDB: copied ${ conversations . length } conversations` ); 194} 195 196if ( messages . length > 0 ) { 197await newDb . table ( IDXDB_TABLES . messages ). bulkAdd ( messages ); 198 199if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) 200console . log ( `[Migration] IndexedDB: copied ${ messages . length } messages` ); 201} 202 203// Non-destructive: DO NOT delete old database - keep for downgrade compatibility 204if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) 205console . log ( '[Migration] IndexedDB: preserved old database for downgrade compatibility' ); 206} 207}; 208 209// Migration 3: Legacy Message Format 210 211// eslint-disable-next-line padding-line-between-statements -- comment header separates this const group 212const LEGACY_MESSAGE_MIGRATION_ID = 'legacy-message-format-v2' ; 213 214interface ParsedTurn { 215textBefore :string ; 216toolCalls :Array <{ name :string ; args :string ; result :string }>; 217} 218 219function parseLegacyToolCalls ( content :string ) :ParsedTurn [] { 220const turns :ParsedTurn [] = []; 221const regex = new RegExp ( LEGACY_AGENTIC_REGEX . COMPLETED_TOOL_CALL . source , 'g' ); 222 223let lastIndex = 0 ; 224let currentTurn :ParsedTurn = { textBefore :'' , toolCalls :[] }; 225let match ; 226 227while (( match = regex . exec ( content )) !== null ) { 228const textBefore = content . slice ( lastIndex , match . index ). trim (); 229 230if ( textBefore && currentTurn . toolCalls . length > 0 ) { 231turns . push ( currentTurn ); 232currentTurn = { textBefore, toolCalls :[] }; 233} else if ( textBefore && currentTurn . toolCalls . length === 0 ) { 234currentTurn . textBefore = textBefore ; 235} 236 237currentTurn . toolCalls . push ({ 238args :match [ 2 ], 239name :match [ 1 ], 240result :match [ 3 ]. replace ( / ^\n+|\n+$ / g , '' ) 241}); 242 243lastIndex = match . index + match [ 0 ]. length ; 244} 245 246const remainingText = content . slice ( lastIndex ). trim (); 247 248if ( currentTurn . toolCalls . length > 0 ) { 249turns . push ( currentTurn ); 250} 251 252if ( remainingText ) { 253const cleanRemaining = remainingText 254. replace ( LEGACY_AGENTIC_REGEX . AGENTIC_TOOL_CALL_OPEN , '' ) 255. trim (); 256 257if ( cleanRemaining ) { 258turns . push ({ textBefore :cleanRemaining , toolCalls :[] }); 259} 260} 261 262if ( turns . length === 0 ) { 263turns . push ({ textBefore :content . trim (), toolCalls :[] }); 264} 265 266return turns ; 267} 268 269function extractLegacyReasoning ( content :string ) :{ reasoning :string ; cleanContent :string } { 270let reasoning = '' ; 271let cleanContent = content ; 272 273const re = new RegExp ( LEGACY_AGENTIC_REGEX . REASONING_EXTRACT . source , 'g' ); 274 275let match ; 276 277while (( match = re . exec ( content )) !== null ) { 278reasoning += match [ 1 ]; 279} 280 281cleanContent = cleanContent 282. replace ( new RegExp ( LEGACY_AGENTIC_REGEX . REASONING_BLOCK . source , 'g' ), '' ) 283. replace ( LEGACY_AGENTIC_REGEX . REASONING_OPEN , '' ); 284 285return { cleanContent, reasoning}; 286} 287 288function hasLegacyMarkers ( content :string ) :boolean { 289return LEGACY_AGENTIC_REGEX . HAS_LEGACY_MARKERS . test ( content ); 290} 291 292let DatabaseService :typeof import ( './database.service' ). DatabaseService | null = null ; 293 294async function getDatabaseService () { 295if ( ! DatabaseService ) { 296const module = await import ( './database.service' ); 297 298DatabaseService = module . DatabaseService ; 299} 300 301return DatabaseService ; 302} 303 304const legacyMessageMigration :Migration = { 305description :'Migrate legacy marker-based messages to structured format' , 306id :LEGACY_MESSAGE_MIGRATION_ID , 307 308async run () :Promise < void > { 309const db = await getDatabaseService (); 310const conversations = await db . getAllConversations (); 311 312let migratedCount = 0 ; 313 314for ( const conv of conversations ) { 315const allMessages = await db . getConversationMessages ( conv . id ); 316 317for ( const message of allMessages ) { 318if ( message . role !== MessageRole . ASSISTANT ) { 319if ( message . content ?. includes ( LEGACY_REASONING_TAGS . START )) { 320const { cleanContent, reasoning} = extractLegacyReasoning ( message . content ); 321 322await db . updateMessage ( message . id , { 323content :cleanContent . trim (), 324reasoningContent :reasoning || undefined 325}); 326migratedCount ++ ; 327} 328 329continue ; 330} 331 332if ( ! hasLegacyMarkers ( message . content ?? '' )) continue ; 333 334const { cleanContent, reasoning} = extractLegacyReasoning ( message . content ); 335const turns = parseLegacyToolCalls ( cleanContent ); 336 337let existingToolCalls :Array <{ 338id :string ; 339function ?:{ name :string ; arguments :string }; 340}> = []; 341 342if ( message . toolCalls ) { 343try { 344existingToolCalls = JSON . parse ( message . toolCalls ); 345} catch { 346// Ignore 347} 348} 349 350const firstTurn = turns [ 0 ]; 351 352if ( ! firstTurn ) continue ; 353 354const firstTurnToolCalls = firstTurn . toolCalls . map (( tc , i ) => { 355const existing = 356existingToolCalls . find (( e ) => e . function ?. name === tc . name ) || existingToolCalls [ i ]; 357 358return { 359function :{ arguments :tc . args , name :tc . name }, 360id :existing ?. id || `legacy_tool_ ${ i } ` , 361type :'function' as const 362}; 363}); 364 365await db . updateMessage ( message . id , { 366content :firstTurn . textBefore , 367reasoningContent :reasoning || undefined , 368toolCalls :firstTurnToolCalls . length > 0 ?JSON . stringify ( firstTurnToolCalls ) :'' 369}); 370 371let currentParentId = message . id ; 372let toolCallIdCounter = existingToolCalls . length ; 373 374for ( let i = 0 ; i < firstTurn . toolCalls . length ; i ++ ) { 375const tc = firstTurn . toolCalls [ i ]; 376const toolCallId = firstTurnToolCalls [ i ]?. id || `legacy_tool_ ${ i } ` ; 377const toolMsg = await db . createMessageBranch ( 378{ 379children :[], 380content :tc . result , 381convId :conv . id , 382role :MessageRole . TOOL , 383timestamp :message . timestamp + i + 1 , 384 toolCallId, 385toolCalls :'' , 386type :'text' 387}, 388currentParentId 389); 390 391currentParentId = toolMsg . id ; 392} 393 394for ( let turnIdx = 1 ; turnIdx < turns . length ; turnIdx ++ ) { 395const turn = turns [ turnIdx ]; 396const turnToolCalls = turn . toolCalls . map (( tc , i ) => { 397const idx = toolCallIdCounter + i ; 398const existing = existingToolCalls [ idx ]; 399 400return { 401function :{ arguments :tc . args , name :tc . name }, 402id :existing ?. id || `legacy_tool_ ${ idx } ` , 403type :'function' as const 404}; 405}); 406 407toolCallIdCounter += turn . toolCalls . length ; 408 409const assistantMsg = await db . createMessageBranch ( 410{ 411children :[], 412content :turn . textBefore , 413convId :conv . id , 414model :message . model , 415role :MessageRole . ASSISTANT , 416timestamp :message . timestamp + turnIdx * 100 , 417toolCalls :turnToolCalls . length > 0 ?JSON . stringify ( turnToolCalls ) :'' , 418type :'text' 419}, 420currentParentId 421); 422 423currentParentId = assistantMsg . id ; 424 425for ( let i = 0 ; i < turn . toolCalls . length ; i ++ ) { 426const tc = turn . toolCalls [ i ]; 427const toolCallId = turnToolCalls [ i ]?. id || `legacy_tool_ ${ toolCallIdCounter + i } ` ; 428const toolMsg = await db . createMessageBranch ( 429{ 430children :[], 431content :tc . result , 432convId :conv . id , 433role :MessageRole . TOOL , 434timestamp :message . timestamp + turnIdx * 100 + i + 1 , 435 toolCallId, 436toolCalls :'' , 437type :'text' 438}, 439currentParentId 440); 441 442currentParentId = toolMsg . id ; 443} 444} 445 446if ( message . children . length > 0 && currentParentId !== message . id ) { 447for ( const childId of message . children ) { 448const child = allMessages . find (( m ) => m . id === childId ); 449 450if ( ! child ) continue ; 451 452if ( child . role !== MessageRole . TOOL ) { 453await db . updateMessage ( childId , { parent :currentParentId }); 454} 455} 456await db . updateMessage ( message . id , { children :[] }); 457} 458 459migratedCount ++ ; 460} 461} 462 463if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) 464console . log ( `[Migration] Legacy messages: migrated ${ migratedCount } messages` ); 465} 466}; 467 468// Migration 4: Theme Key (Non-Destructive) 469 470// eslint-disable-next-line padding-line-between-statements -- comment header separates this const group 471const THEME_MIGRATION_ID = 'theme-key-v1' ; 472const themeMigration :Migration = { 473description :'Copy standalone theme key to config object (non-destructive)' , 474id :THEME_MIGRATION_ID , 475 476async run () :Promise < void > { 477const legacyTheme = localStorage . getItem ( 'theme' ); 478 479if ( legacyTheme === null ) { 480if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) 481console . log ( '[Migration] Theme: no legacy theme key found, skipping' ); 482 483return ; 484} 485 486// Check if config already has theme 487const configRaw = localStorage . getItem ( CONFIG_LOCALSTORAGE_KEY ); 488const config = configRaw ?JSON . parse ( configRaw ) :{}; 489 490if ( SETTINGS_KEYS . THEME in config ) { 491if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) 492console . log ( '[Migration] Theme: config already has theme, skipping' ); 493 494return ; 495} 496 497config [ SETTINGS_KEYS . THEME ] = legacyTheme ; 498localStorage . setItem ( CONFIG_LOCALSTORAGE_KEY , JSON . stringify ( config )); 499 500// Non-destructive: DO NOT delete legacy theme key - keep for downgrade compatibility 501if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) 502console . log ( `[Migration] Theme: copied standalone theme to config (preserved old key)` ); 503} 504}; 505 506// Migration Registry & Runner 507 508// eslint-disable-next-line padding-line-between-statements -- comment header separates this const group 509const CUSTOM_JSON_MIGRATION_ID = 'custom-json-key-v1' ; 510const customJsonKeyMigration :Migration = { 511description :'Copy legacy custom config key to customJson (non-destructive)' , 512id :CUSTOM_JSON_MIGRATION_ID , 513 514async run () :Promise < void > { 515const configRaw = localStorage . getItem ( CONFIG_LOCALSTORAGE_KEY ); 516 517if ( configRaw === null ) return ; 518 519const config = JSON . parse ( configRaw ); 520 521if ( ! ( 'custom' in config )) return ; 522 523if ( SETTINGS_KEYS . CUSTOM_JSON in config ) return ; 524 525config [ SETTINGS_KEYS . CUSTOM_JSON ] = config . custom ; 526localStorage . setItem ( CONFIG_LOCALSTORAGE_KEY , JSON . stringify ( config )); 527 528// Non-destructive: keep the legacy custom key for downgrade compatibility 529if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) 530console . log ( `[Migration] Custom JSON: copied custom to customJson (preserved old key)` ); 531} 532}; 533const MCP_DEFAULT_ENABLED_MIGRATION_ID = 'mcp-default-enabled-to-config-v1' ; 534const LEGACY_MCP_DEFAULT_ENABLED_KEY = ` ${ STORAGE_APP_NAME } .mcpDefaultEnabled` ; 535const DEPRECATED_LEGACY_MCP_DEFAULT_ENABLED_KEY = ` ${ STORAGE_APP_NAME_DEPRECATED } .mcpDefaultEnabled` ; 536const mcpDefaultEnabledMigration :Migration = { 537description : 538'Copy mcpDefaultEnabled localStorage key into settings config (preserves legacy keys)' , 539id :MCP_DEFAULT_ENABLED_MIGRATION_ID , 540 541async run () :Promise < void > { 542const raw = 543localStorage . getItem ( LEGACY_MCP_DEFAULT_ENABLED_KEY ) ?? 544localStorage . getItem ( DEPRECATED_LEGACY_MCP_DEFAULT_ENABLED_KEY ); 545 546// Legacy keys intentionally left in place so a downgrade keeps reading them. 547 548if ( raw === null ) { 549if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) 550console . log ( '[Migration] MCP default enabled: no legacy key found, skipping' ); 551 552return ; 553} 554 555const configRaw = localStorage . getItem ( CONFIG_LOCALSTORAGE_KEY ); 556const config = configRaw ?JSON . parse ( configRaw ) :{}; 557 558// Don't overwrite an existing config entry — current data wins. 559if ( MCP_DEFAULT_OVERRIDES_LEGACY_KEY in config ) { 560if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) 561console . log ( '[Migration] MCP default enabled: config already has overrides, skipping' ); 562 563return ; 564} 565 566try { 567const parsed = JSON . parse ( raw ); 568 569if ( ! Array . isArray ( parsed )) return ; 570 571const valid = parsed . every ( 572( o ) => 573typeof o === 'object' && 574o !== null && 575typeof ( o as Record < string , unknown >). serverId === 'string' && 576typeof ( o as Record < string , unknown >). enabled === 'boolean' 577); 578 579if ( ! valid ) return ; 580} catch { 581return ; 582} 583 584config [ MCP_DEFAULT_OVERRIDES_LEGACY_KEY ] = raw ; 585localStorage . setItem ( CONFIG_LOCALSTORAGE_KEY , JSON . stringify ( config )); 586 587if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) 588console . log ( '[Migration] MCP default enabled: moved legacy key into config' ); 589} 590}; 591const CONFIG_TYPES_MIGRATION_ID = 'config-type-normalization-v1' ; 592const configTypesMigration :Migration = { 593description :'Coerce legacy string-encoded booleans in persisted config to real booleans' , 594id :CONFIG_TYPES_MIGRATION_ID , 595 596async run () :Promise < void > { 597const configRaw = localStorage . getItem ( CONFIG_LOCALSTORAGE_KEY ); 598 599if ( configRaw === null ) return ; 600 601const config = JSON . parse ( configRaw ); 602 603let changed = false ; 604 605// Pre-schema configs persisted booleans as "true"/"false" strings; the strict server 606// schema rejects them. No config string field holds exactly "true"/"false", so the 607// match is unambiguous. 608for ( const key of Object . keys ( config )) { 609if ( config [ key ] === BooleanString . TRUE ) { 610config [ key ] = true ; 611changed = true ; 612} else if ( config [ key ] === BooleanString . FALSE ) { 613config [ key ] = false ; 614changed = true ; 615} 616} 617 618if ( changed ) { 619localStorage . setItem ( CONFIG_LOCALSTORAGE_KEY , JSON . stringify ( config )); 620} 621 622if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) 623console . log ( `[Migration] Config types: coerced string booleans (changed= ${ changed } )` ); 624} 625}; 626const RENDER_KEYS_MIGRATION_ID = 'render-keys-unfold-v1' ; 627const LEGACY_RENDER_RAW_TEXT_KEY = 'renderContentAsRawText' ; 628const renderKeysMigration :Migration = { 629description :'Unfold the single raw text render toggle onto the per-surface render keys' , 630id :RENDER_KEYS_MIGRATION_ID , 631 632async run () :Promise < void > { 633const configRaw = localStorage . getItem ( CONFIG_LOCALSTORAGE_KEY ); 634 635if ( configRaw === null ) return ; 636 637const config = JSON . parse ( configRaw ); 638 639if ( ! ( LEGACY_RENDER_RAW_TEXT_KEY in config )) return ; 640 641// The toggle carried user content and thinking at once and cannot say which surface 642// was chosen, so it only restores the user key and thinking keeps its own default. 643if ( ! ( SETTINGS_KEYS . RENDER_USER_CONTENT_AS_MARKDOWN in config )) { 644config [ SETTINGS_KEYS . RENDER_USER_CONTENT_AS_MARKDOWN ] = 645config [ LEGACY_RENDER_RAW_TEXT_KEY ] !== true ; 646} 647 648// Dropped rather than preserved: the two render keys and the toggle describe the same 649// surfaces, so leaving it behind would let a stale value fight the restored one. 650delete config [ LEGACY_RENDER_RAW_TEXT_KEY ]; 651localStorage . setItem ( CONFIG_LOCALSTORAGE_KEY , JSON . stringify ( config )); 652 653if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) 654console . log ( '[Migration] Render keys: unfolded the raw text toggle' ); 655} 656}; 657const MCP_DEFAULT_OVERRIDES_LEGACY_KEY = ` ${ STORAGE_APP_NAME } .mcpDefaultServerOverrides` ; 658const MCP_DEFAULT_OVERRIDES_MERGE_MIGRATION_ID = 'mcp-default-overrides-merge-v1' ; 659/** 660* Folds `mcpDefaultServerOverrides` (the legacy "default for new chats" list, 661* JSON-encoded as `[{ serverId, enabled }, ...]`) into `mcpServers[i].enabled`. 662* The legacy override key is intentionally left in the config so a downgrade 663* keeps reading it. Runs after `mcpDefaultEnabledMigration` so any legacy 664* standalone overrides are already inside the config. 665*/ 666const mcpDefaultOverridesMergeMigration :Migration = { 667description : 668'Merge mcpDefaultServerOverrides entries onto mcpServers[i].enabled (preserves legacy key)' , 669id :MCP_DEFAULT_OVERRIDES_MERGE_MIGRATION_ID , 670 671async run () :Promise < void > { 672const configRaw = localStorage . getItem ( CONFIG_LOCALSTORAGE_KEY ); 673 674if ( configRaw === null ) return ; 675 676const config = JSON . parse ( configRaw ); 677const raw = config [ MCP_DEFAULT_OVERRIDES_LEGACY_KEY ]; 678 679if ( typeof raw !== 'string' || raw . length === 0 ) { 680if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) 681console . log ( '[Migration] MCP default overrides merge: nothing to merge' ); 682 683return ; 684} 685 686let overrides :{ serverId :string ; enabled :boolean }[]; 687 688try { 689const parsed = JSON . parse ( raw ); 690 691if ( ! Array . isArray ( parsed )) return ; 692 693overrides = parsed . filter ( 694( o ) => 695typeof o === 'object' && 696o !== null && 697typeof ( o as Record < string , unknown >). serverId === 'string' && 698typeof ( o as Record < string , unknown >). enabled === 'boolean' 699) as { serverId :string ; enabled :boolean }[]; 700} catch { 701return ; 702} 703 704const serversRaw = config [ SETTINGS_KEYS . MCP_SERVERS ]; 705 706let servers :{ id :string ; enabled ?:boolean }[]; 707 708try { 709servers = typeof serversRaw === 'string' ?JSON . parse ( serversRaw ) :[]; 710} catch { 711return ; 712} 713 714if ( ! Array . isArray ( servers )) servers = []; 715 716let serversChanged = false ; 717 718const knownIds = new Set ( servers . map (( s ) => s . id )); 719 720for ( const override of overrides ) { 721if ( ! knownIds . has ( override . serverId )) continue ; 722 723const index = servers . findIndex (( s ) => s . id === override . serverId ); 724 725if ( index >= 0 && servers [ index ]. enabled !== override . enabled ) { 726servers [ index ] = { ...servers [ index ], enabled :override . enabled }; 727serversChanged = true ; 728} 729} 730 731if ( serversChanged ) { 732config [ SETTINGS_KEYS . MCP_SERVERS ] = JSON . stringify ( servers ); 733localStorage . setItem ( CONFIG_LOCALSTORAGE_KEY , JSON . stringify ( config )); 734} 735 736if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) 737console . log ( 738`[Migration] MCP default overrides merge: applied= ${ overrides . length } serversChanged= ${ serversChanged } (legacy key preserved)` 739); 740} 741}; 742const MCP_SERVER_OVERRIDES_TO_TOOL_POLICY_MIGRATION_ID = 'mcp-server-overrides-to-tool-policy-v1' ; 743const mcpServerOverridesToToolPolicyMigration :Migration = { 744description : 745'Seed per-conversation disabled tool keys from the global defaults and legacy per-conversation MCP server overrides (legacy field preserved)' , 746id :MCP_SERVER_OVERRIDES_TO_TOOL_POLICY_MIGRATION_ID , 747 748async run () :Promise < void > { 749// The global disabled set used to apply to every conversation; it is now 750// the defaults seeded into newly created conversations, so existing rows 751// are seeded with it to keep their behavior unchanged. 752let defaults :string [] = []; 753 754try { 755const raw = localStorage . getItem ( DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY ); 756 757if ( raw ) { 758const parsed :unknown = JSON . parse ( raw ); 759 760if ( Array . isArray ( parsed )) { 761defaults = parsed . filter (( k ) :k isstring => typeof k === 'string' ); 762} 763} 764} catch { 765// fall through with empty defaults so legacy overrides still migrate 766} 767 768const db = await getDatabaseService (); 769const conversations = await db . getAllConversations (); 770 771let migratedCount = 0 ; 772 773for ( const conv of conversations ) { 774// re-run safety: a row that already has a policy is left alone 775if ( conv . disabledTools !== undefined ) continue ; 776 777// A legacy per-conversation server disable becomes a server-scoped tool 778// key (same format as toolsStore.getMcpServerToolsKey). Per-conversation 779// enables are dropped: the global server flag governs now. 780const serverGroupKeys = ( conv . mcpServerOverrides ?? []) 781. filter (( o :McpServerOverride ) => ! o . enabled ) 782. map (( o :McpServerOverride ) => `mcp: ${ o . serverId } ` ); 783const disabledTools = [ ...new Set ([ ...defaults , ...serverGroupKeys ])]; 784 785if ( disabledTools . length === 0 ) continue ; 786 787await db . updateConversation ( conv . id , { disabledTools}); 788migratedCount ++ ; 789} 790 791if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) 792console . log ( 793`[Migration] MCP server overrides -> tool policy: updated ${ migratedCount } conversations` 794); 795} 796}; 797const migrations :Migration [] = [ 798localStorageMigration , 799idxdbMigration , 800legacyMessageMigration , 801themeMigration , 802customJsonKeyMigration , 803mcpDefaultEnabledMigration , 804mcpDefaultOverridesMergeMigration , 805configTypesMigration , 806renderKeysMigration , 807mcpServerOverridesToToolPolicyMigration 808]; 809 810export const MigrationService = { 811/** 812* Get all registered migrations 813*/ 814getMigrations () :Migration [] { 815return [ ...migrations ]; 816}, 817 818/** 819* Get current migration state 820*/ 821getState () :MigrationState { 822return getMigrationState (); 823}, 824 825/** 826* Check if a specific migration has been completed 827*/ 828isCompleted ( id :string ) :boolean { 829return isMigrationCompleted ( id ); 830}, 831 832/** 833* Reset migration state (use with caution - migrations will run again) 834*/ 835resetState () :void { 836localStorage . removeItem ( MIGRATION_STATE_KEY ); 837 838if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) 839console . log ( '[Migration] State reset - all migrations will run again' ); 840}, 841 842/** 843* Run all pending migrations (non-destructive - preserves legacy data) 844* Should be called once at app initialization 845*/ 846async runAllMigrations () :Promise < void > { 847const state = getMigrationState (); 848 849if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) 850console . log ( '[Migration] Starting migration run, state:' , state ); 851 852for ( const migration of migrations ) { 853if ( isMigrationCompleted ( migration . id )) { 854if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) 855console . log ( `[Migration] ${ migration . id } : already completed, skipping` ); 856 857continue ; 858} 859 860try { 861if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) 862console . log ( `[Migration] ${ migration . id } : running...` ); 863 864await migration . run (); 865markMigrationCompleted ( migration . id ); 866 867if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) 868console . log ( `[Migration] ${ migration . id } : completed successfully` ); 869} catch ( error ) { 870console . error ( `[Migration] ${ migration . id } : failed` , error ); 871markMigrationFailed ( migration . id ); 872} 873} 874 875if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) 876console . log ( '[Migration] All migrations complete' ); 877} 878};