Path: blob/main/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingExplanationModelManager.ts
5241 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js';6import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js';7import { ResourceMap } from '../../../../../base/common/map.js';8import { IObservable, observableValue } from '../../../../../base/common/observable.js';9import { basename } from '../../../../../base/common/resources.js';10import { URI } from '../../../../../base/common/uri.js';11import { ITextModel } from '../../../../../editor/common/model.js';12import { DetailedLineRangeMapping, LineRangeMapping } from '../../../../../editor/common/diff/rangeMapping.js';13import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js';14import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js';15import { ChatMessageRole, ILanguageModelsService } from '../../common/languageModels.js';16import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js';17import * as nls from '../../../../../nls.js';1819/**20* Simple diff info interface for explanation generation21*/22export interface IExplanationDiffInfo {23readonly changes: readonly (LineRangeMapping | DetailedLineRangeMapping)[];24readonly identical: boolean;25readonly originalModel: ITextModel;26readonly modifiedModel: ITextModel;27}2829/**30* A single explanation for a change31*/32export interface IChangeExplanation {33readonly uri: URI;34readonly startLineNumber: number;35readonly endLineNumber: number;36readonly originalText: string;37readonly modifiedText: string;38readonly explanation: string;39}4041/**42* Progress state for explanation generation43*/44export type ExplanationProgress = 'idle' | 'loading' | 'complete' | 'error';4546/**47* Explanation state for a single URI48*/49export interface IExplanationState {50readonly progress: ExplanationProgress;51readonly explanations: readonly IChangeExplanation[];52readonly diffInfo: IExplanationDiffInfo;53readonly chatSessionResource: URI | undefined;54readonly errorMessage?: string;55}5657/**58* Handle returned when generating explanations59*/60export interface IExplanationGenerationHandle extends IDisposable {61/**62* The URIs being explained63*/64readonly uris: readonly URI[];6566/**67* Promise that resolves when generation is complete68*/69readonly completed: Promise<void>;70}7172export const IChatEditingExplanationModelManager = createDecorator<IChatEditingExplanationModelManager>('chatEditingExplanationModelManager');7374export interface IChatEditingExplanationModelManager {75readonly _serviceBrand: undefined;7677/**78* Observable map from URI to explanation state.79* When a URI has state, explanations are shown. When removed, they are hidden.80* UI code can use autorun or derived to react to state changes.81*/82readonly state: IObservable<ResourceMap<IExplanationState>>;8384/**85* Generates explanations for the given diff infos using a single LLM request.86* This allows the model to understand the complete change across files.87* Returns a disposable handle for lifecycle management.88* The generation can be cancelled by disposing the handle or via the cancellation token.89* Disposing the handle also removes the explanations from the state.90*91* State is updated per-file as explanations are parsed from the response.92*93* @param diffInfos Array of diff info objects, one per file94* @param chatSessionResource Chat session resource for follow-up actions95* @param token Cancellation token for external cancellation control96* @returns A handle with disposal and completion tracking97*/98generateExplanations(diffInfos: readonly IExplanationDiffInfo[], chatSessionResource: URI | undefined, token: CancellationToken): IExplanationGenerationHandle;99}100101/**102* Gets the text content for a change103*/104function getChangeTexts(change: LineRangeMapping | DetailedLineRangeMapping, diffInfo: IExplanationDiffInfo): { originalText: string; modifiedText: string } {105const originalLines: string[] = [];106const modifiedLines: string[] = [];107108// Get original text109for (let i = change.original.startLineNumber; i < change.original.endLineNumberExclusive; i++) {110const line = diffInfo.originalModel.getLineContent(i);111originalLines.push(line);112}113114// Get modified text115for (let i = change.modified.startLineNumber; i < change.modified.endLineNumberExclusive; i++) {116const line = diffInfo.modifiedModel.getLineContent(i);117modifiedLines.push(line);118}119120return {121originalText: originalLines.join('\n'),122modifiedText: modifiedLines.join('\n')123};124}125126export class ChatEditingExplanationModelManager extends Disposable implements IChatEditingExplanationModelManager {127declare readonly _serviceBrand: undefined;128129private readonly _state = observableValue<ResourceMap<IExplanationState>>(this, new ResourceMap<IExplanationState>());130readonly state: IObservable<ResourceMap<IExplanationState>> = this._state;131132constructor(133@ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService,134) {135super();136}137138private _updateUriState(uri: URI, uriState: IExplanationState): void {139const current = this._state.get();140const newState = new ResourceMap<IExplanationState>(current);141newState.set(uri, uriState);142this._state.set(newState, undefined);143}144145private _updateUriStatePartial(uri: URI, partial: Partial<IExplanationState>): void {146const current = this._state.get();147const existing = current.get(uri);148if (existing) {149const newState = new ResourceMap<IExplanationState>(current);150newState.set(uri, { ...existing, ...partial });151this._state.set(newState, undefined);152}153}154155private _removeUris(uris: readonly URI[]): void {156const current = this._state.get();157const newState = new ResourceMap<IExplanationState>(current);158for (const uri of uris) {159newState.delete(uri);160}161this._state.set(newState, undefined);162}163164generateExplanations(diffInfos: readonly IExplanationDiffInfo[], chatSessionResource: URI | undefined, token: CancellationToken): IExplanationGenerationHandle {165const uris = diffInfos.map(d => d.modifiedModel.uri);166const cts = new CancellationTokenSource(token);167168// Set loading state for all URIs with diffInfo and chatSessionResource169for (const diffInfo of diffInfos) {170this._updateUriState(diffInfo.modifiedModel.uri, {171progress: 'loading',172explanations: [],173diffInfo,174chatSessionResource,175});176}177178const completed = this._doGenerateExplanations(diffInfos, cts.token);179180return {181uris,182completed,183dispose: () => {184cts.dispose(true);185this._removeUris(uris);186}187};188}189190private async _doGenerateExplanations(diffInfos: readonly IExplanationDiffInfo[], cancellationToken: CancellationToken): Promise<void> {191// Filter out empty diffs and fire empty events for them192const nonEmptyDiffs: IExplanationDiffInfo[] = [];193for (const diffInfo of diffInfos) {194if (diffInfo.changes.length === 0 || diffInfo.identical) {195this._updateUriStatePartial(diffInfo.modifiedModel.uri, {196progress: 'complete',197explanations: [],198});199} else {200nonEmptyDiffs.push(diffInfo);201}202}203204if (nonEmptyDiffs.length === 0) {205return;206}207208// Build change data for all files209interface FileChangeData {210uri: URI;211fileName: string;212changes: {213startLineNumber: number;214endLineNumber: number;215originalText: string;216modifiedText: string;217}[];218}219220const fileChanges: FileChangeData[] = nonEmptyDiffs.map(diffInfo => {221const uri = diffInfo.modifiedModel.uri;222const fileName = basename(uri);223const changes = diffInfo.changes.map(change => {224const { originalText, modifiedText } = getChangeTexts(change, diffInfo);225return {226startLineNumber: change.modified.startLineNumber,227endLineNumber: change.modified.endLineNumberExclusive - 1,228originalText,229modifiedText,230};231});232return { uri, fileName, changes };233});234235// Total number of changes across all files236const totalChanges = fileChanges.reduce((sum, f) => sum + f.changes.length, 0);237238try {239// Select a high-end model for better understanding of all changes together240let models = await this._languageModelsService.selectLanguageModels({ vendor: 'copilot', family: 'claude-3.5-sonnet' });241if (!models.length) {242models = await this._languageModelsService.selectLanguageModels({ vendor: 'copilot', family: 'gpt-4o' });243}244if (!models.length) {245models = await this._languageModelsService.selectLanguageModels({ vendor: 'copilot', family: 'gpt-4' });246}247if (!models.length) {248// Fallback to any available model249models = await this._languageModelsService.selectLanguageModels({ vendor: 'copilot' });250}251if (!models.length) {252for (const fileData of fileChanges) {253this._updateUriStatePartial(fileData.uri, {254progress: 'error',255explanations: [],256errorMessage: nls.localize('noModelAvailable', "No language model available"),257});258}259return;260}261262if (cancellationToken.isCancellationRequested) {263return;264}265266// Build a prompt with all changes from all files267let changeIndex = 0;268const changesDescription = fileChanges.map(fileData => {269return fileData.changes.map(data => {270const desc = `=== CHANGE ${changeIndex} (File: ${fileData.fileName}, Lines ${data.startLineNumber}-${data.endLineNumber}) ===271BEFORE:272${data.originalText || '(empty)'}273274AFTER:275${data.modifiedText || '(empty)'}`;276changeIndex++;277return desc;278}).join('\n\n');279}).join('\n\n');280281const fileCount = fileChanges.length;282const prompt = `Analyze these ${totalChanges} code changes across ${fileCount} file${fileCount > 1 ? 's' : ''} and provide a brief explanation for each one.283These changes are part of a single coherent modification, so consider how they relate to each other.284285${changesDescription}286287Respond with a JSON array containing exactly ${totalChanges} objects, one for each change in order.288Each object should have an "explanation" field with a brief sentence (max 15 words) explaining what changed and why.289Be specific about the actual code changes. Return ONLY valid JSON, no markdown.290291Example response format:292[{"explanation": "Added null check to prevent crash"}, {"explanation": "Renamed variable for clarity"}]`;293294const response = await this._languageModelsService.sendChatRequest(295models[0],296new ExtensionIdentifier('core'),297[{ role: ChatMessageRole.User, content: [{ type: 'text', value: prompt }] }],298{},299cancellationToken300);301302let responseText = '';303for await (const part of response.stream) {304if (cancellationToken.isCancellationRequested) {305return;306}307if (Array.isArray(part)) {308for (const p of part) {309if (p.type === 'text') {310responseText += p.value;311}312}313} else if (part.type === 'text') {314responseText += part.value;315}316}317318await response.result;319320if (cancellationToken.isCancellationRequested) {321return;322}323324// Parse the JSON response325let parsed: { explanation: string }[] = [];326try {327// Handle potential markdown wrapping328let jsonText = responseText.trim();329if (jsonText.startsWith('```')) {330jsonText = jsonText.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');331}332parsed = JSON.parse(jsonText);333} catch {334// JSON parsing failed - will use default messages335}336337// Map explanations back to files338let parsedIndex = 0;339for (const fileData of fileChanges) {340const explanations: IChangeExplanation[] = [];341for (const data of fileData.changes) {342const parsedExplanation = parsed[parsedIndex]?.explanation?.trim() || nls.localize('codeWasModified', "Code was modified.");343explanations.push({344uri: fileData.uri,345startLineNumber: data.startLineNumber,346endLineNumber: data.endLineNumber,347originalText: data.originalText,348modifiedText: data.modifiedText,349explanation: parsedExplanation,350});351parsedIndex++;352}353354this._updateUriStatePartial(fileData.uri, {355progress: 'complete',356explanations,357});358}359} catch (e) {360if (!cancellationToken.isCancellationRequested) {361const errorMessage = e instanceof Error ? e.message : nls.localize('explanationFailed', "Failed to generate explanations");362for (const fileData of fileChanges) {363this._updateUriStatePartial(fileData.uri, {364progress: 'error',365explanations: [],366errorMessage,367});368}369}370}371}372}373374registerSingleton(IChatEditingExplanationModelManager, ChatEditingExplanationModelManager, InstantiationType.Delayed);375376377