diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index c1335f963..86967a75e 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -395,7 +395,6 @@ "Failed to delete file: {0}": "Failed to delete file: {0}", "Failed to delete item \"{0}\".": "Failed to delete item \"{0}\".", "Failed to delete secrets for item \"{0}\".": "Failed to delete secrets for item \"{0}\".", - "Failed to deploy LLM instruction files: {0}": "Failed to deploy LLM instruction files: {0}", "Failed to extract the account endpoint from the selected node.": "Failed to extract the account endpoint from the selected node.", "Failed to extract the connection string from the selected account.": "Failed to extract the connection string from the selected account.", "Failed to find a matching Azure Resources API for version \"{0}\".": "Failed to find a matching Azure Resources API for version \"{0}\".", @@ -614,7 +613,6 @@ "No history": "No history", "No index metrics available": "No index metrics available", "No language models available. Please ensure you have access to Copilot.": "No language models available. Please ensure you have access to Copilot.", - "No LLM instructions (.md) files found to copy": "No LLM instructions (.md) files found to copy", "No location selected. Please pick a location before provisioning.": "No location selected. Please pick a location before provisioning.", "No matching resources found.": "No matching resources found.", "No properties available for filtering.": "No properties available for filtering.", @@ -843,7 +841,6 @@ "Some sample items were not inserted successfully:": "Some sample items were not inserted successfully:", "Some selected files are outside the workspace. Would you like to copy them to the migration project?": "Some selected files are outside the workspace. Would you like to copy them to the migration project?", "Sorted by:": "Sorted by:", - "Source folder not found: {0}": "Source folder not found: {0}", "Specified character lengths should be 1 character or greater.": "Specified character lengths should be 1 character or greater.", "Start Migration": "Start Migration", "Stats": "Stats", @@ -863,7 +860,6 @@ "subscription": "subscription", "Subscription: {0}": "Subscription: {0}", "Subscription: {id}": "Subscription: {id}", - "Successfully copied {0} LLM instructions (.md) files": "Successfully copied {0} LLM instructions (.md) files", "Successfully created resource group \"{0}\".": "Successfully created resource group \"{0}\".", "Successfully created role assignment \"{0}\" for the {1} resource \"{2}\".": "Successfully created role assignment \"{0}\" for the {1} resource \"{2}\".", "Successfully created storage account \"{0}\" with sku \"{1}\".": "Successfully created storage account \"{0}\" with sku \"{1}\".", @@ -991,7 +987,6 @@ "Unexpected status code: {0}": "Unexpected status code: {0}", "Unknown error": "Unknown error", "Unknown Error": "Unknown Error", - "Unknown error occurred": "Unknown error occurred", "Unknown migration command: {name}": "Unknown migration command: {name}", "Unknown operation: {0}": "Unknown operation: {0}", "Unsupported Account": "Unsupported Account", diff --git a/package.json b/package.json index 39513eba9..0eba1a2c8 100644 --- a/package.json +++ b/package.json @@ -480,16 +480,6 @@ "command": "cosmosDB.importDocument", "title": "%cosmosdb.command.document.import%" }, - { - "category": "CosmosDB AI", - "command": "cosmosDB.ai.deployInstructionFiles", - "title": "%cosmosdb.command.ai.deploy_instruction_files%" - }, - { - "category": "CosmosDB AI", - "command": "cosmosDB.ai.removeInstructionFiles", - "title": "%cosmosdb.command.ai.remove_instruction_files%" - }, { "category": "Cosmos DB", "command": "cosmosDB.deleteAllSavedSchemas", @@ -914,11 +904,6 @@ "default": 6128, "description": "%cosmosdb.configuration.cosmosDB.shell.mcp.port%" }, - "cosmosDB.manageLLMAssets": { - "type": "boolean", - "default": true, - "markdownDescription": "%cosmosdb.configuration.manage-llm-assets%" - }, "cosmosDB.queryEditor.generateSchemaBasedOnQueries": { "type": "boolean", "default": true, diff --git a/resources/llm-assets/.gitkeep b/resources/llm-assets/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/commands/registerCommands.ts b/src/commands/registerCommands.ts index ae69901ad..389567c69 100644 --- a/src/commands/registerCommands.ts +++ b/src/commands/registerCommands.ts @@ -13,10 +13,6 @@ import * as l10n from '@vscode/l10n'; import * as vscode from 'vscode'; import { CosmosDbChatParticipant } from '../chat'; import { doubleClickDebounceDelay } from '../constants'; -import { - deployLLMInstructionsFiles, - removeLLMInstructionsFiles, -} from '../cosmosdb/commands/deployLLMInstructionsFiles'; import { ext } from '../extensionVariables'; import { QueryEditorTab } from '../panels/QueryEditorTab'; import { copyConnectionString } from './copyConnectionString/copyConnectionString'; @@ -82,7 +78,6 @@ export function registerCommands(): void { registerCommandWithTreeNodeUnwrapping('azureDatabases.filterTreeItems', filterTreeItems); registerCommandWithTreeNodeUnwrapping('azureDatabases.sortTreeItems', sortTreeItems); - registerLLMAssetsCommands(); registerChatButtonCommands(); registerMigrationCommands(); } @@ -135,11 +130,6 @@ export function registerTriggerCommands() { registerCommandWithTreeNodeUnwrapping('cosmosDB.deleteTrigger', cosmosDBDeleteTrigger); } -export function registerLLMAssetsCommands() { - registerCommand('cosmosDB.ai.deployInstructionFiles', deployLLMInstructionsFiles); - registerCommand('cosmosDB.ai.removeInstructionFiles', removeLLMInstructionsFiles); -} - export function registerChatButtonCommands() { // Command to apply the suggested query (update current editor) // Note: Chat buttons pass arguments directly, so we use vscode.commands.registerCommand diff --git a/src/cosmosdb/commands/cleanupLLMInstructionsFiles.ts b/src/cosmosdb/commands/cleanupLLMInstructionsFiles.ts new file mode 100644 index 000000000..8d369e1a6 --- /dev/null +++ b/src/cosmosdb/commands/cleanupLLMInstructionsFiles.ts @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { callWithTelemetryAndErrorHandling } from '@microsoft/vscode-azext-utils'; +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import { ext } from '../../extensionVariables'; + +const LLM_ASSETS_MANIFEST_KEY = 'llm.assets.manifest'; +const INSTRUCTIONS_FILENAME = 'azurecosmosdb.instructions.md'; +const EXPECTED_MD5 = 'A54B319AD86975EB072A681AFA9CB553'; + +/** + * Cleans up the obsolete LLM instructions file that was previously deployed + * to the VS Code prompts folder by older versions of this extension. + * + * - Deletes the file only if its MD5 hash matches the known unmodified hash + * - Clears the obsolete manifest key from globalState + * + * This is idempotent and safe to call on every activation. + */ +export async function cleanupLLMInstructionsFiles(): Promise { + await callWithTelemetryAndErrorHandling('cosmosDB.llmInstructions.cleanup', async (context) => { + context.errorHandling.suppressDisplay = true; + context.errorHandling.rethrow = false; + + // Remove the file if it exists and hasn't been modified + const promptFolder = getPromptFolder(); + const filePath = path.join(promptFolder, INSTRUCTIONS_FILENAME); + + // Mask file paths from any error telemetry (fs errors often include paths) + context.valuesToMask.push(promptFolder, filePath); + const fileExists = fs.existsSync(filePath); + context.telemetry.properties.fileFound = String(fileExists); + + if (fileExists) { + const content = fs.readFileSync(filePath); + const actualMd5 = crypto.createHash('md5').update(content).digest('hex').toUpperCase(); + const md5Matched = actualMd5 === EXPECTED_MD5; + context.telemetry.properties.md5Matched = String(md5Matched); + ext.outputChannel.info( + `Found existing instructions file "${INSTRUCTIONS_FILENAME}" in prompts folder (md5Matched=${md5Matched})`, + ); + + if (md5Matched) { + fs.unlinkSync(filePath); + context.telemetry.properties.fileDeleted = String(true); + ext.outputChannel.info( + `Deleted obsolete instructions file "${INSTRUCTIONS_FILENAME}" from prompts folder`, + ); + } + } + + // Clear the obsolete manifest from globalState + await ext.context.globalState.update(LLM_ASSETS_MANIFEST_KEY, undefined); + }); +} + +/** + * Returns the VS Code user-level prompts folder, matching the path used + * by the old deployLLMInstructionsFiles logic. + */ +function getPromptFolder(): string { + const globalStorageUri = ext.context.globalStorageUri; + const userFolder = path.dirname(globalStorageUri.fsPath); + return path.join(userFolder, '..', 'prompts'); +} diff --git a/src/cosmosdb/commands/deployLLMInstructionsFiles.test.ts b/src/cosmosdb/commands/deployLLMInstructionsFiles.test.ts deleted file mode 100644 index 83df481c0..000000000 --- a/src/cosmosdb/commands/deployLLMInstructionsFiles.test.ts +++ /dev/null @@ -1,427 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { type IActionContext } from '@microsoft/vscode-azext-utils'; -import { type Mock } from 'vitest'; -import { deployLLMInstructionsFiles, removeLLMInstructionsFiles } from './deployLLMInstructionsFiles'; - -// Mock the callWithTelemetryAndErrorHandling function -vi.mock('@microsoft/vscode-azext-utils', () => ({ - callWithTelemetryAndErrorHandling: vi.fn(), -})); - -// Mock all dependencies -vi.mock('../../extensionVariables', () => ({ - ext: { - context: { - globalStorageUri: { - fsPath: 'C:\\Users\\test\\.vscode\\extensions\\storage', - }, - extension: { - packageJSON: { - version: '1.0.0', - }, - }, - globalState: { - get: vi.fn().mockReturnValue('{}'), - update: vi.fn(), - }, - }, - }, -})); - -vi.mock('../../services/SettingsService', () => ({ - SettingsService: { - getSetting: vi.fn(), - }, -})); - -vi.mock('fs', () => ({ - existsSync: vi.fn(), - mkdirSync: vi.fn(), - readdirSync: vi.fn(), - copyFileSync: vi.fn(), - readFileSync: vi.fn(), - unlinkSync: vi.fn(), -})); - -vi.mock('path', () => { - const mock = { - dirname: vi.fn(), - join: vi.fn(), - extname: vi.fn(), - }; - return { ...mock, default: mock }; -}); - -vi.mock('@vscode/l10n', () => ({ - t: vi.fn(), -})); - -import { callWithTelemetryAndErrorHandling } from '@microsoft/vscode-azext-utils'; -import * as l10n from '@vscode/l10n'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as vscode from 'vscode'; -import { ext } from '../../extensionVariables'; -import { SettingsService } from '../../services/SettingsService'; - -describe('LLM Instructions Files', () => { - let mockTelemetryContext: IActionContext; - - // Helper function to create mock Dirent objects - const createMockDirent = (name: string, isFile = true) => ({ - name, - isFile: () => isFile, - isDirectory: () => !isFile, - isBlockDevice: () => false, - isCharacterDevice: () => false, - isSymbolicLink: () => false, - isFIFO: () => false, - isSocket: () => false, - }); - - beforeEach(() => { - vi.clearAllMocks(); - - // Setup mock telemetry context that will be passed to the callback - mockTelemetryContext = { - telemetry: { - properties: {}, - measurements: {}, - }, - } as IActionContext; - - // Mock the callWithTelemetryAndErrorHandling function - (callWithTelemetryAndErrorHandling as Mock).mockImplementation( - (_eventName: string, callback: (context: IActionContext) => void) => { - return callback(mockTelemetryContext); - }, - ); - - // Mock fs functions - (fs.existsSync as Mock).mockReturnValue(true); - (fs.mkdirSync as Mock).mockImplementation(() => {}); - (fs.readdirSync as Mock).mockReturnValue([]); - (fs.copyFileSync as Mock).mockImplementation(() => {}); - (fs.readFileSync as Mock).mockReturnValue('test content'); - (fs.unlinkSync as Mock).mockImplementation(() => {}); - - // Mock path functions - (path.dirname as Mock).mockImplementation((p: string) => { - if (p === 'C:\\Users\\test\\.vscode\\extensions\\storage') { - return 'C:\\Users\\test\\.vscode\\extensions'; - } - return p; - }); - (path.join as Mock).mockImplementation((...paths: string[]) => paths.join('\\')); - (path.extname as Mock).mockImplementation((p: string) => { - const lastDot = p.lastIndexOf('.'); - return lastDot >= 0 ? p.substring(lastDot) : ''; - }); - - // Mock l10n - (l10n.t as Mock).mockImplementation((message: string, ...args: any[]) => { - const templates: Record = { - 'Source folder not found: {0}': `Source folder not found: ${args[0]}`, - 'Successfully copied {0} LLM instructions (.md) files': `Successfully copied ${args[0]} LLM instructions (.md) files`, - 'Successfully deleted {0} LLM instructions (.md) files': `Successfully deleted ${args[0]} LLM instructions (.md) files`, - 'No LLM instructions (.md) files found to copy': 'No LLM instructions (.md) files found to copy', - 'No LLM instructions (.md) files found to delete': 'No LLM instructions (.md) files found to delete', - 'Failed to deploy LLM instruction files: {0}': `Failed to deploy LLM instruction files: ${args[0]}`, - 'Unknown error occurred': 'Unknown error occurred', - Close: 'Close', - }; - return templates[message] || message; - }); - - // Setup console mocks - vi.spyOn(console, 'log').mockImplementation(() => {}); - vi.spyOn(console, 'error').mockImplementation(() => {}); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('deployLLMInstructionsFiles', () => { - describe('when manageLLMAssets setting is disabled', () => { - it('should skip deployment and return early', async () => { - // Arrange - (SettingsService.getSetting as Mock).mockReturnValue(false); - - // Act - await deployLLMInstructionsFiles({} as IActionContext); - - // Assert - expect(console.log).toHaveBeenCalledWith( - 'Skipping deployLLMInstructionsFiles because manageLLMAssets is disabled', - ); - expect(fs.existsSync).not.toHaveBeenCalled(); - expect(mockTelemetryContext.telemetry.properties.skipped).toBe('true'); - }); - }); - - describe('when manageLLMAssets setting is enabled', () => { - it('should create prompt folder if it does not exist', async () => { - // Arrange - (SettingsService.getSetting as Mock).mockReturnValue(true); - (fs.existsSync as Mock).mockImplementation((path: string) => { - return path !== 'C:\\Users\\test\\.vscode\\extensions\\..\\prompts'; - }); - - // Act - await deployLLMInstructionsFiles({} as IActionContext); - - // Assert - expect(fs.mkdirSync).toHaveBeenCalledWith('C:\\Users\\test\\.vscode\\extensions\\..\\prompts', { - recursive: true, - }); - expect(mockTelemetryContext.telemetry.properties.skipped).toBe('false'); - }); - - it('should handle source folder not found error', async () => { - // Arrange - (SettingsService.getSetting as Mock).mockReturnValue(true); - (fs.existsSync as Mock).mockImplementation((path: string) => { - return !path.toString().includes('llm-assets'); - }); - - // Act - await deployLLMInstructionsFiles({} as IActionContext); - - // Assert - expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( - expect.stringContaining('Failed to deploy LLM instruction files'), - 'Close', - ); - expect(mockTelemetryContext.telemetry.properties.skipped).toBe('false'); - }); - - it('should copy new .md files', async () => { - // Arrange - (SettingsService.getSetting as Mock).mockReturnValue(true); - const mockFiles = [ - createMockDirent('test1.md', true), - createMockDirent('test2.md', true), - createMockDirent('readme.txt', true), // Should be ignored - createMockDirent('subfolder', false), // Should be ignored - ]; - (fs.readdirSync as Mock).mockReturnValue(mockFiles); - (fs.existsSync as Mock).mockImplementation((path: string) => { - // Source folder exists, but destination files don't exist - return !path.includes('.md') || path.includes('llm-assets'); - }); - (fs.readFileSync as Mock).mockReturnValue('file content'); - - // Act - await deployLLMInstructionsFiles({} as IActionContext); - - // Assert - expect(fs.copyFileSync).toHaveBeenCalledTimes(2); - expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( - 'Successfully copied 2 LLM instructions (.md) files', - 'Close', - ); - expect(vscode.window.createStatusBarItem).not.toHaveBeenCalled(); - expect(mockTelemetryContext.telemetry.properties.count).toBe('2'); - }); - - it('should skip identical files and show status bar', async () => { - // Arrange - (SettingsService.getSetting as Mock).mockReturnValue(true); - const mockFiles = [createMockDirent('test1.md', true), createMockDirent('test2.md', true)]; - (fs.readdirSync as Mock).mockReturnValue(mockFiles); - (fs.existsSync as Mock).mockReturnValue(true); // All files exist - (fs.readFileSync as Mock).mockReturnValue('identical content'); // Same content - const mockStatusBar = { - text: '', - show: vi.fn(), - dispose: vi.fn(), - }; - (vscode.window.createStatusBarItem as Mock).mockReturnValue(mockStatusBar); - - // Act - await deployLLMInstructionsFiles({} as IActionContext); - - // Assert - expect(fs.copyFileSync).not.toHaveBeenCalled(); // No files copied because they're identical - expect(console.log).toHaveBeenCalledWith('Skipped test1.md as it is unchanged'); - expect(console.log).toHaveBeenCalledWith('Skipped test2.md as it is unchanged'); - expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); - expect(vscode.window.createStatusBarItem).toHaveBeenCalled(); - expect(mockStatusBar.text).toBe('No LLM instructions (.md) files found to copy'); - expect(mockTelemetryContext.telemetry.properties.count).toBe('0'); - }); - - it('should show status bar when no .md files found', async () => { - // Arrange - (SettingsService.getSetting as Mock).mockReturnValue(true); - const mockFiles = [createMockDirent('readme.txt', true), createMockDirent('subfolder', false)]; - (fs.readdirSync as Mock).mockReturnValue(mockFiles); - const mockStatusBar = { - text: '', - show: vi.fn(), - dispose: vi.fn(), - }; - (vscode.window.createStatusBarItem as Mock).mockReturnValue(mockStatusBar); - - // Act - await deployLLMInstructionsFiles({} as IActionContext); - - // Assert - expect(fs.copyFileSync).not.toHaveBeenCalled(); - expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); - expect(vscode.window.createStatusBarItem).toHaveBeenCalled(); - expect(mockStatusBar.text).toBe('No LLM instructions (.md) files found to copy'); - expect(mockStatusBar.show).toHaveBeenCalled(); - expect(mockTelemetryContext.telemetry.properties.count).toBe('0'); - }); - - it('should handle errors gracefully', async () => { - // Arrange - (SettingsService.getSetting as Mock).mockReturnValue(true); - const error = new Error('Test error'); - (fs.readdirSync as Mock).mockImplementation(() => { - throw error; - }); - - // Act - await deployLLMInstructionsFiles({} as IActionContext); - - // Assert - expect(console.error).toHaveBeenCalledWith('Error deploying AI instructions files:', error); - expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( - 'Failed to deploy LLM instruction files: Test error', - 'Close', - ); - }); - - it('should handle case-insensitive file extensions', async () => { - // Arrange - (SettingsService.getSetting as Mock).mockReturnValue(true); - const mockFiles = [ - createMockDirent('test1.MD', true), - createMockDirent('test2.Md', true), - createMockDirent('test3.mD', true), - ]; - (fs.readdirSync as Mock).mockReturnValue(mockFiles); - (fs.existsSync as Mock).mockImplementation((path: string) => { - // Source folder exists, but destination files don't exist for uppercase variants - if (path.includes('llm-assets')) return true; // Source folder exists - if (path.includes('.MD') || path.includes('.Md') || path.includes('.mD')) return false; // Destination files don't exist - return true; // Other paths exist - }); - (fs.readFileSync as Mock).mockReturnValue('content'); - - // Act - await deployLLMInstructionsFiles({} as IActionContext); - - // Assert - expect(fs.copyFileSync).toHaveBeenCalledTimes(3); - expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( - 'Successfully copied 3 LLM instructions (.md) files', - 'Close', - ); - expect(vscode.window.createStatusBarItem).not.toHaveBeenCalled(); - expect(mockTelemetryContext.telemetry.properties.count).toBe('3'); - }); - - it('should delete obsolete files from previous deployment', async () => { - // Arrange - (SettingsService.getSetting as Mock).mockReturnValue(true); - const previousManifest = { - files: { - 'old-file.md': { status: 'deployed' }, - 'still-exists.md': { status: 'deployed' }, - }, - }; - const mockFiles = [createMockDirent('still-exists.md', true), createMockDirent('new-file.md', true)]; - - (ext.context.globalState.get as Mock).mockReturnValue(JSON.stringify(previousManifest)); - (fs.readdirSync as Mock).mockReturnValue(mockFiles); - (fs.existsSync as Mock).mockImplementation((path: string) => { - return !path.includes('new-file.md') || path.includes('llm-assets'); - }); - (fs.readFileSync as Mock).mockReturnValue('file content'); - - // Act - await deployLLMInstructionsFiles({} as IActionContext); - - // Assert - expect(fs.unlinkSync).toHaveBeenCalledWith(expect.stringContaining('old-file.md')); - expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Deleted obsolete')); - }); - }); - }); - - describe('removeLLMInstructionsFiles', () => { - it('should remove all files listed in manifest', async () => { - // Arrange - const manifest = { - files: { - 'file1.md': { status: 'deployed' }, - 'file2.md': { status: 'deployed' }, - 'file3.md': { status: 'deployed' }, - }, - }; - (ext.context.globalState.get as Mock).mockReturnValue(JSON.stringify(manifest)); - (fs.existsSync as Mock).mockReturnValue(true); - - // Act - await removeLLMInstructionsFiles({} as IActionContext); - - // Assert - expect(fs.unlinkSync).toHaveBeenCalledTimes(3); - expect(fs.unlinkSync).toHaveBeenCalledWith(expect.stringContaining('file1.md')); - expect(fs.unlinkSync).toHaveBeenCalledWith(expect.stringContaining('file2.md')); - expect(fs.unlinkSync).toHaveBeenCalledWith(expect.stringContaining('file3.md')); - expect(ext.context.globalState.update).toHaveBeenCalledWith('llm.assets.manifest', undefined); - expect(mockTelemetryContext.telemetry.properties.count).toBe('3'); - expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( - 'Successfully deleted 3 LLM instructions (.md) files', - 'Close', - ); - }); - - it('should handle files that do not exist on disk', async () => { - // Arrange - const manifest = { - files: { - 'nonexistent.md': { status: 'deployed' }, - 'existing.md': { status: 'deployed' }, - }, - }; - (ext.context.globalState.get as Mock).mockReturnValue(JSON.stringify(manifest)); - (fs.existsSync as Mock).mockImplementation((path: string) => { - return path.includes('existing.md'); - }); - - // Act - await removeLLMInstructionsFiles({} as IActionContext); - - // Assert - expect(fs.unlinkSync).toHaveBeenCalledTimes(1); - expect(fs.unlinkSync).toHaveBeenCalledWith(expect.stringContaining('existing.md')); - expect(mockTelemetryContext.telemetry.properties.count).toBe('1'); - }); - - it('should show message when no files found to delete', async () => { - // Arrange - (ext.context.globalState.get as Mock).mockReturnValue('{}'); - - // Act - await removeLLMInstructionsFiles({} as IActionContext); - - // Assert - expect(fs.unlinkSync).not.toHaveBeenCalled(); - expect(mockTelemetryContext.telemetry.properties.count).toBe('0'); - expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( - 'No LLM instructions (.md) files found to delete', - 'Close', - ); - }); - }); -}); diff --git a/src/cosmosdb/commands/deployLLMInstructionsFiles.ts b/src/cosmosdb/commands/deployLLMInstructionsFiles.ts deleted file mode 100644 index 7999bf073..000000000 --- a/src/cosmosdb/commands/deployLLMInstructionsFiles.ts +++ /dev/null @@ -1,179 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; -import * as l10n from '@vscode/l10n'; -import * as fs from 'fs'; -import path from 'path'; -import * as vscode from 'vscode'; -import { ext } from '../../extensionVariables'; -import { SettingsService } from '../../services/SettingsService'; - -const LLM_ASSETS_MANIFEST_KEY = 'llm.assets.manifest'; - -/** - * Remove LLM files listed in manifest - * @param _ - */ -export const removeLLMInstructionsFiles = async (_: IActionContext): Promise => { - await callWithTelemetryAndErrorHandling('cosmosDB.llm.llmassets.remove', (context: IActionContext) => { - const promptFolder = getPromptFolder(); - let count = 0; - - // Delete the files that were deployed and logged in manifest - const previousManifest = JSON.parse( - ext.context.globalState.get(LLM_ASSETS_MANIFEST_KEY) || '{}', - ) as IDeploymentManifest; - for (const fileName in previousManifest.files) { - const filePath = path.join(promptFolder, fileName); - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath); - console.log(`Deleted ${filePath}`); - count++; - } - } - - context.telemetry.properties.count = count.toString(); - - // Clear manifest - ext.context.globalState.update(LLM_ASSETS_MANIFEST_KEY, undefined); - - vscode.window.showInformationMessage( - l10n.t( - count > 0 - ? 'Successfully deleted {0} LLM instructions (.md) files' - : 'No LLM instructions (.md) files found to delete', - count, - ), - l10n.t('Close'), - ); - }); -}; - -export const deployLLMInstructionsFiles = async (_: IActionContext): Promise => { - await callWithTelemetryAndErrorHandling('cosmosDB.llm.llmassets.deploy', (context: IActionContext) => { - context.telemetry.properties.skipped = 'false'; - - const manageFiles = SettingsService.getSetting('cosmosDB.manageLLMAssets') ?? true; - if (!manageFiles) { - context.telemetry.properties.skipped = 'true'; - // TODO: add telemetry - console.log('Skipping deployLLMInstructionsFiles because manageLLMAssets is disabled'); - return; - } - - const promptFolder = getPromptFolder(); - console.log('deployLLMInstructionsFiles to', promptFolder); - - const manifest: IDeploymentManifest = { - extensionVersion: (ext.context.extension.packageJSON as { version: string }).version, - deploymentTimestamp: Date.now(), - files: {}, - }; - - try { - // Create prompt folder if it doesn't exist - if (!fs.existsSync(promptFolder)) { - fs.mkdirSync(promptFolder, { recursive: true }); - } - - // Get the path to the source folder - const sourceFolder = path.join(ext.context.extensionPath, 'resources', 'llm-assets'); - - // Check if source folder exists - if (!fs.existsSync(sourceFolder)) { - throw new Error(l10n.t('Source folder not found: {0}', sourceFolder)); - } - - // Read all files in the llm-instructions folder - const files = fs.readdirSync(sourceFolder, { withFileTypes: true }); - const copiedFiles: string[] = []; - - for (const file of files) { - if (file.isFile() && path.extname(file.name).toLowerCase() === '.md') { - const sourceFile = path.join(sourceFolder, file.name); - const destinationFile = path.join(promptFolder, file.name); - - // Copy each .md file only if source and destination differ. - if ( - !fs.existsSync(destinationFile) || - fs.readFileSync(sourceFile, 'utf8') !== fs.readFileSync(destinationFile, 'utf8') - ) { - fs.copyFileSync(sourceFile, destinationFile); - copiedFiles.push(file.name); - console.log(`Copied ${file.name} to ${destinationFile}`); - } else { - console.log(`Skipped ${file.name} as it is unchanged`); - } - // Add file to manifest - manifest.files[file.name] = { status: 'deployed' }; - } - } - - // Delete the files that were deployed previously that aren't part of the current deployment - const previousManifest = JSON.parse( - ext.context.globalState.get(LLM_ASSETS_MANIFEST_KEY) || '{}', - ) as IDeploymentManifest; - for (const fileName in previousManifest.files) { - if (!manifest.files[fileName]) { - const filePath = path.join(promptFolder, fileName); - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath); - console.log(`Deleted obsolete ${filePath}`); - } - } - } - - context.telemetry.properties.count = copiedFiles.length.toString(); - - // Save manifest file to global storage - ext.context.globalState.update('llm.assets.manifest', JSON.stringify(manifest, null, 2)); - console.log(`Saved manifest file to global storage`); - - if (copiedFiles.length > 0) { - void vscode.window.showInformationMessage( - l10n.t('Successfully copied {0} LLM instructions (.md) files', copiedFiles.length), - l10n.t('Close'), - ); - } else { - // Show result in status bar for more discrete message - const statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); - statusBar.text = l10n.t('No LLM instructions (.md) files found to copy'); - statusBar.show(); - setTimeout(() => statusBar.dispose(), 3000); - } - } catch (error) { - console.error('Error deploying AI instructions files:', error); - - // Show error dialog - const errorMessage = error instanceof Error ? error.message : l10n.t('Unknown error occurred'); - void vscode.window.showErrorMessage( - l10n.t('Failed to deploy LLM instruction files: {0}', errorMessage), - l10n.t('Close'), - ); - } - }); -}; - -const getPromptFolder = () => { - const globalStorageUri = ext.context.globalStorageUri; - const userFolder = path.dirname(globalStorageUri.fsPath); - const promptFolder = path.join(userFolder, '..', 'prompts'); - return promptFolder; -}; - -/** - * The manifest records the last files deployment. - * If files in the last deployment are not part of the current deployment, they will be deleted. - */ -interface IDeploymentManifest { - extensionVersion: string; - deploymentTimestamp: number; - files: { - [fileName: string]: { - status: 'deployed' | 'skipped'; - }; - }; -} diff --git a/src/extension.ts b/src/extension.ts index 75305606a..67203bdb9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -33,6 +33,7 @@ import * as vscode from 'vscode'; import { CosmosDbChatParticipant, registerSampleDataTool } from './chat'; import { registerE2eTestCommands } from './commands/e2eTestCommands/registerE2eTestCommands'; import { registerCommands } from './commands/registerCommands'; +import { cleanupLLMInstructionsFiles } from './cosmosdb/commands/cleanupLLMInstructionsFiles'; import { SCHEMA_STORAGE_KEY } from './cosmosdb/cosmosdb-shared-constants'; import { getIsRunningOnAzure } from './cosmosdb/utils/managedIdentityUtils'; import { @@ -89,6 +90,9 @@ export async function activateInternal( // This is idempotent and safe to call on every activation void SchemaFileStorage.getInstance().migrateFromGlobalState(SCHEMA_STORAGE_KEY); + // Remove obsolete LLM instruction files and clear the manifest from globalState + void cleanupLLMInstructionsFiles(); + // Early initialization to determine whether Managed Identity is available for authentication void getIsRunningOnAzure(); @@ -219,8 +223,6 @@ export async function activateInternal( console.log(`Registering APIs: ${exportedApi.apiVersion}, Azure Resources API ${clientApi.apiVersion}`); - vscode.commands.executeCommand('cosmosDB.ai.deployInstructionFiles'); - return createApiProvider([clientApi]); }