diff --git a/packages/ai-native/src/browser/ai-core.contribution.ts b/packages/ai-native/src/browser/ai-core.contribution.ts index 1d121a38a8..ee343ae131 100644 --- a/packages/ai-native/src/browser/ai-core.contribution.ts +++ b/packages/ai-native/src/browser/ai-core.contribution.ts @@ -260,7 +260,7 @@ export class AINativeBrowserContribution register( IntelligentCompletionsController.ID, new SyncDescriptor(IntelligentCompletionsController, [this.injector]), - EditorContributionInstantiation.AfterFirstRender, + EditorContributionInstantiation.Eager, ); register( InlineCompletionsController.ID, diff --git a/packages/ai-native/src/browser/contrib/intelligent-completions/index.ts b/packages/ai-native/src/browser/contrib/intelligent-completions/index.ts index 853ce52c1c..56dce0cf34 100644 --- a/packages/ai-native/src/browser/contrib/intelligent-completions/index.ts +++ b/packages/ai-native/src/browser/contrib/intelligent-completions/index.ts @@ -4,6 +4,12 @@ import { IModelContentChangedEvent, IPosition, IRange, InlineCompletion } from ' import type { ILineChangeData } from './source/line-change.source'; import type { ILinterErrorData } from './source/lint-error.source'; +/** + * 有效弃用时间(毫秒) + * 在可见的情况下超过 750ms 弃用才算有效数据,否则视为无效数据 + */ +export const VALID_TIME = 750; + export interface IIntelligentCompletionsResult { readonly items: InlineCompletion[]; /** diff --git a/packages/ai-native/src/browser/contrib/intelligent-completions/intelligent-completions.controller.ts b/packages/ai-native/src/browser/contrib/intelligent-completions/intelligent-completions.controller.ts index 5d8f10176f..128dd434ca 100644 --- a/packages/ai-native/src/browser/contrib/intelligent-completions/intelligent-completions.controller.ts +++ b/packages/ai-native/src/browser/contrib/intelligent-completions/intelligent-completions.controller.ts @@ -1,4 +1,10 @@ -import { Key, KeybindingRegistry, KeybindingScope, PreferenceService } from '@opensumi/ide-core-browser'; +import { + ContextKeyChangeEvent, + Key, + KeybindingRegistry, + KeybindingScope, + PreferenceService, +} from '@opensumi/ide-core-browser'; import { MultiLineEditsIsVisible } from '@opensumi/ide-core-browser/lib/contextkey/ai-native'; import { AINativeSettingSectionsId, @@ -19,6 +25,9 @@ import { autorun, autorunWithStoreHandleChanges, derived, + derivedHandleChanges, + derivedOpts, + observableFromEvent, observableSignal, observableValue, transaction, @@ -52,7 +61,7 @@ import { LineChangeCodeEditsSource } from './source/line-change.source'; import { LintErrorCodeEditsSource } from './source/lint-error.source'; import { TypingCodeEditsSource } from './source/typing.source'; -import { CodeEditsResultValue } from './index'; +import { CodeEditsResultValue, VALID_TIME } from './index'; export class IntelligentCompletionsController extends BaseAIMonacoEditorController { public static readonly ID = 'editor.contrib.ai.intelligent.completions'; @@ -87,8 +96,8 @@ export class IntelligentCompletionsController extends BaseAIMonacoEditorControll private codeEditsSourceCollection: CodeEditsSourceCollection; private aiNativeContextKey: AINativeContextKey; private rewriteWidget: RewriteWidget | null; - private whenMultiLineEditsVisibleDisposable: Disposable; private codeEditsTriggerSignal: IObservableSignal; + private multiLineEditsIsVisibleObs: IObservable; public mount(): IDisposable { this.handlerAlwaysVisiblePreference(); @@ -96,7 +105,6 @@ export class IntelligentCompletionsController extends BaseAIMonacoEditorControll this.codeEditsResult = observableValue(this, undefined); this.codeEditsTriggerSignal = observableSignal(this); - this.whenMultiLineEditsVisibleDisposable = new Disposable(); this.multiLineDecorationModel = new MultiLineDecorationModel(this.monacoEditor); this.additionsDeletionsDecorationModel = new AdditionsDeletionsDecorationModel(this.monacoEditor); this.aiNativeContextKey = this.injector.get(AINativeContextKey, [this.monacoEditor.contextKeyService]); @@ -105,6 +113,15 @@ export class IntelligentCompletionsController extends BaseAIMonacoEditorControll this.monacoEditor, ]); + const multiLineEditsIsVisibleKey = new Set([MultiLineEditsIsVisible.raw]); + this.multiLineEditsIsVisibleObs = observableFromEvent( + this, + Event.filter(this.aiNativeContextKey.contextKeyService!.onDidChangeContext, (e: ContextKeyChangeEvent) => + e.payload.affectsSome(multiLineEditsIsVisibleKey), + ), + () => !!this.aiNativeContextKey.multiLineEditsIsVisible.get(), + ); + this.registerFeature(this.monacoEditor); return this.featureDisposable; } @@ -259,26 +276,6 @@ export class IntelligentCompletionsController extends BaseAIMonacoEditorControll this.additionsDeletionsDecorationModel.updateDeletionsDecoration(wordChanges, range, eol); this.renderRewriteWidget(wordChanges, model, range, insertTextString); } - - if (this.whenMultiLineEditsVisibleDisposable.disposed) { - this.whenMultiLineEditsVisibleDisposable = new Disposable(); - } - // 监听当前光标位置的变化,如果超出 range 区域则表示弃用 - this.whenMultiLineEditsVisibleDisposable.addDispose( - this.monacoEditor.onDidChangeCursorPosition((event: ICursorPositionChangedEvent) => { - const isVisible = this.aiNativeContextKey.multiLineEditsIsVisible.get(); - if (isVisible) { - const position = event.position; - if (position.lineNumber < range.startLineNumber || position.lineNumber > range.endLineNumber) { - runWhenIdle(() => { - this.discard.get(); - }); - } - } else { - this.whenMultiLineEditsVisibleDisposable.dispose(); - } - }), - ); } private async renderRewriteWidget( @@ -341,36 +338,52 @@ export class IntelligentCompletionsController extends BaseAIMonacoEditorControll const { range, insertText } = codeEditsResult.items[0]; const newCode = insertText; const originCode = this.model.getValueInRange(range); - return (type: keyof Pick) => { - contextBean.reporterEnd({ - [type]: true, + return (type: keyof Pick, defaultValue: boolean = true) => { + const data = { + [type]: defaultValue, code: newCode, originCode, - }); + }; + + contextBean.reporterEnd(data); }; } }); - private lastVisibleTime = derived(this, (reader) => { - const isVisible = this.aiNativeContextKey.multiLineEditsIsVisible.get(); - return isVisible ? Date.now() : undefined; - }); - - public discard = derived(this, (reader) => { - const lastVisibleTime = this.lastVisibleTime.read(reader); - const report = this.reportData.read(reader); - - // 在可见的情况下超过 750ms 弃用才算有效数据,否则视为取消 - if (lastVisibleTime && Date.now() - lastVisibleTime > 750) { - report?.('isDrop'); - } else { - report?.('isValid'); - } + public discard = derivedHandleChanges( + { + owner: this, + createEmptyChangeSummary: () => ({ lastVisibleTime: Date.now() }), + handleChange: (context, changeSummary) => { + if (context.didChange(this.multiLineEditsIsVisibleObs)) { + changeSummary.lastVisibleTime = Date.now(); + return this.multiLineEditsIsVisibleObs.get(); + } + return false; + }, + equalityComparer: () => false, + }, + (reader, changeSummary) => { + this.multiLineEditsIsVisibleObs.read(reader); + + const lastVisibleTime = changeSummary.lastVisibleTime; + const report = this.reportData.read(reader); + let isValid = false; + + if (lastVisibleTime && Date.now() - lastVisibleTime > VALID_TIME) { + isValid = true; + report?.('isDrop'); + } else { + isValid = false; + report?.('isValid', false); + } - this.hide(); - }); + this.hide(); + return isValid; + }, + ); - public accept = derived(this, (reader) => { + public accept = derivedOpts({ owner: this, equalsFn: () => false }, (reader) => { const report = this.reportData.read(reader); report?.('isReceive'); @@ -413,14 +426,19 @@ export class IntelligentCompletionsController extends BaseAIMonacoEditorControll }), ); - const multiLineEditsIsVisibleKey = new Set([MultiLineEditsIsVisible.raw]); - this.featureDisposable.addDispose(this.whenMultiLineEditsVisibleDisposable); + // 监听当前光标位置的变化,如果超出 range 区域则表示弃用 this.featureDisposable.addDispose( - this.aiNativeContextKey.contextKeyService!.onDidChangeContext((e) => { - if (e.payload.affectsSome(multiLineEditsIsVisibleKey)) { - const isVisible = this.aiNativeContextKey.multiLineEditsIsVisible.get(); - if (!isVisible) { - this.whenMultiLineEditsVisibleDisposable.dispose(); + this.monacoEditor.onDidChangeCursorPosition((event: ICursorPositionChangedEvent) => { + const isVisible = this.multiLineEditsIsVisibleObs.get(); + const completionModel = this.codeEditsResult.get(); + + if (isVisible && completionModel) { + const position = event.position; + const range = completionModel.items[0].range; + if (position.lineNumber < range.startLineNumber || position.lineNumber > range.endLineNumber) { + runWhenIdle(() => { + this.discard.get(); + }); } } }), diff --git a/packages/ai-native/src/browser/contrib/intelligent-completions/source/line-change.source.ts b/packages/ai-native/src/browser/contrib/intelligent-completions/source/line-change.source.ts index 5c579be462..5298a3ee6e 100644 --- a/packages/ai-native/src/browser/contrib/intelligent-completions/source/line-change.source.ts +++ b/packages/ai-native/src/browser/contrib/intelligent-completions/source/line-change.source.ts @@ -1,7 +1,15 @@ import { Injectable } from '@opensumi/di'; import { AINativeSettingSectionsId, ECodeEditsSourceTyping, IDisposable } from '@opensumi/ide-core-common'; -import { ICursorPositionChangedEvent } from '@opensumi/ide-monaco'; -import { autorunDelta, observableFromEvent } from '@opensumi/ide-monaco/lib/common/observable'; +import { ICursorPositionChangedEvent, IModelContentChangedEvent } from '@opensumi/ide-monaco'; +import { + autorunDelta, + derived, + derivedHandleChanges, + observableFromEvent, + onObservableChange, +} from '@opensumi/ide-monaco/lib/common/observable'; + +import { IntelligentCompletionsController } from '../intelligent-completions.controller'; import { BaseCodeEditsSource } from './base'; @@ -10,24 +18,89 @@ export interface ILineChangeData { preLineNumber?: number; } +const DEPRECATED_LIMIT = 5; +const CONTENT_CHANGE_VALID_TIME = 60 * 1000; + @Injectable({ multiple: true }) export class LineChangeCodeEditsSource extends BaseCodeEditsSource { public priority = 1; - public mount(): IDisposable { - const positionChangeObs = observableFromEvent( - this, - this.monacoEditor.onDidChangeCursorPosition, - (event: ICursorPositionChangedEvent) => event, - ); + /** + * 在当前文件,计算弃用上次 edit 时的次数是否超过了阈值 {@link DEPRECATED_LIMIT} 次,超过则不会触发 + * 1. 直接 esc 弃用 + * 2. 用户再次移动光标位置致使补全消失也视为弃用 + */ + private readonly deprecatedStore = new Map(); + + private readonly positionChangeObs = observableFromEvent( + this, + this.monacoEditor.onDidChangeCursorPosition, + (event: ICursorPositionChangedEvent) => event, + ); + + private readonly contentChangeObs = observableFromEvent( + this, + this.monacoEditor.onDidChangeModelContent, + (event: IModelContentChangedEvent) => event, + ); + + private readonly latestContentChangeTimeObs = derivedHandleChanges( + { + owner: this, + createEmptyChangeSummary: () => ({ latestContentChangeTime: 0 }), + handleChange: (context, changeSummary) => { + if (context.didChange(this.contentChangeObs)) { + changeSummary.latestContentChangeTime = Date.now(); + } + return true; + }, + }, + (reader, changeSummary) => { + this.contentChangeObs.read(reader); + return changeSummary.latestContentChangeTime; + }, + ); + + private readonly isAllowTriggerObs = derived((reader) => { + this.positionChangeObs.read(reader); + const latestContentChangeTime = this.latestContentChangeTimeObs.read(reader); + + const isLineChangeEnabled = this.preferenceService.getValid(AINativeSettingSectionsId.CodeEditsLineChange, false); + /** + * 配置开关 + */ + if (!isLineChangeEnabled) { + return false; + } + + /** + * 弃用次数规则的限制 + */ + const deprecatedCount = this.deprecatedStore.get(this.model?.id || ''); + if (deprecatedCount && deprecatedCount >= DEPRECATED_LIMIT) { + return false; + } + + /** + * 1. 未编辑过代码不触发 + * 2. 编辑过代码后,60 内没有再次编辑也不触发 + */ + if ( + latestContentChangeTime === 0 || + (latestContentChangeTime && Date.now() - latestContentChangeTime > CONTENT_CHANGE_VALID_TIME) + ) { + return false; + } + + return true; + }); + + public mount(): IDisposable { this.addDispose( - autorunDelta(positionChangeObs, ({ lastValue, newValue }) => { - const isLineChangeEnabled = this.preferenceService.getValid( - AINativeSettingSectionsId.CodeEditsLineChange, - false, - ); - if (!isLineChangeEnabled) { + autorunDelta(this.positionChangeObs, ({ lastValue, newValue }, reader) => { + const isAllowTriggerObs = this.isAllowTriggerObs.read(reader); + if (!isAllowTriggerObs) { return false; } @@ -47,6 +120,35 @@ export class LineChangeCodeEditsSource extends BaseCodeEditsSource { }), ); + const discard = IntelligentCompletionsController.get(this.monacoEditor)?.discard; + const accept = IntelligentCompletionsController.get(this.monacoEditor)?.accept; + if (discard) { + this.addDispose( + onObservableChange(discard, (isValid: boolean) => { + const modelId = this.model?.id; + if (!modelId || !isValid) { + return; + } + + const count = this.deprecatedStore.get(modelId) || 0; + this.deprecatedStore.set(modelId, count + 1); + }), + ); + } + + if (accept) { + this.addDispose( + onObservableChange(accept, () => { + const modelId = this.model?.id; + if (!modelId) { + return; + } + + this.deprecatedStore.delete(modelId); + }), + ); + } + return this; } } diff --git a/packages/monaco/src/common/observable.ts b/packages/monaco/src/common/observable.ts index 7b4e8e4150..625576cff3 100644 --- a/packages/monaco/src/common/observable.ts +++ b/packages/monaco/src/common/observable.ts @@ -1,5 +1,9 @@ import { autorun, autorunOpts } from '@opensumi/monaco-editor-core/esm/vs/base/common/observableInternal/autorun'; -import { IObservable } from '@opensumi/monaco-editor-core/esm/vs/base/common/observableInternal/base'; +import { + IObservable, + IObserver, + IReader, +} from '@opensumi/monaco-editor-core/esm/vs/base/common/observableInternal/base'; import { IDisposable } from '@opensumi/monaco-editor-core/esm/vs/base/common/observableInternal/commonFacade/deps'; import { observableFromEvent } from '@opensumi/monaco-editor-core/esm/vs/base/common/observableInternal/utils'; @@ -13,14 +17,14 @@ export * from '@opensumi/monaco-editor-core/esm/vs/base/common/observableInterna export function autorunDelta( observable: IObservable, - handler: (args: { lastValue: T | undefined; newValue: T }) => void, + handler: (args: { lastValue: T | undefined; newValue: T }, reader: IReader) => void, ): IDisposable { let _lastValue: T | undefined; return autorunOpts({ debugReferenceFn: handler }, (reader) => { const newValue = observable.read(reader); const lastValue = _lastValue; _lastValue = newValue; - handler({ lastValue, newValue }); + handler({ lastValue, newValue }, reader); }); } @@ -65,3 +69,23 @@ export function debouncedObservable2(observable: IObservable, debounceMs: }, ); } + +export function onObservableChange(observable: IObservable, callback: (value: T) => void): IDisposable { + const o: IObserver = { + beginUpdate() {}, + endUpdate() {}, + handlePossibleChange(observable) { + observable.reportChanges(); + }, + handleChange(_observable: IObservable) { + callback(_observable.get() as unknown as T); + }, + }; + + observable.addObserver(o); + return { + dispose() { + observable.removeObserver(o); + }, + }; +}