Team Ai
Modelpublic

Felipe97/llama-cpp-compiled

sourceHugging Faceupdated 21d agoView on Hugging Face
0likes1.2kdownloads
migration.service.ts879 linesDownload Raw Back to services
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