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