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 cdd5de1e21..fabd22dd06 100644 --- a/packages/ai-native/src/browser/contrib/intelligent-completions/index.ts +++ b/packages/ai-native/src/browser/contrib/intelligent-completions/index.ts @@ -1,6 +1,7 @@ import { IRange, InlineCompletion } from '@opensumi/ide-monaco'; -import type { ILinterErrorData } from './lint-error.source'; +import type { ILineChangeData } from './source/line-change.source'; +import type { ILinterErrorData } from './source/lint-error.source'; export interface IIntelligentCompletionsResult { readonly items: InlineCompletion[]; @@ -12,12 +13,18 @@ export interface IIntelligentCompletionsResult { export enum ECodeEditsSource { LinterErrors = 'lint_errors', + LineChange = 'line_change', } -export interface ICodeEditsContextBean { - typing: ECodeEditsSource.LinterErrors; - data: ILinterErrorData; -} +export type ICodeEditsContextBean = + | { + typing: ECodeEditsSource.LinterErrors; + data: ILinterErrorData; + } + | { + typing: ECodeEditsSource.LineChange; + data: ILineChangeData; + }; export interface ICodeEdit { /** 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 05c9691075..ae0fdb66cd 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 @@ -28,9 +28,9 @@ import { mergeMultiLineDiffChanges, wordChangesToLineChangesMap, } from './diff-computer'; -import { LintErrorCodeEditsSource } from './lint-error.source'; - -import { ICodeEditsResult } from '.'; +import { CodeEditsResultValue, CodeEditsSourceCollection } from './source/base'; +import { LineChangeCodeEditsSource } from './source/line-change.source'; +import { LintErrorCodeEditsSource } from './source/lint-error.source'; export class IntelligentCompletionsController extends BaseAIMonacoEditorController { public static readonly ID = 'editor.contrib.ai.intelligent.completions'; @@ -171,7 +171,7 @@ export class IntelligentCompletionsController extends BaseAIMonacoEditorControll } } - public async fetchProvider(editsResult: ICodeEditsResult): Promise { + private async fetchProvider(editsResult: CodeEditsResultValue): Promise { // 如果上一次补全结果还在,则不重复请求 const isVisible = this.aiNativeContextKey.multiLineEditsIsVisible.get(); if (isVisible) { @@ -183,7 +183,7 @@ export class IntelligentCompletionsController extends BaseAIMonacoEditorControll } } - private applyInlineDecorations(completionModel: ICodeEditsResult) { + private applyInlineDecorations(completionModel: CodeEditsResultValue) { const { items } = completionModel; const { range, insertText } = items[0]; @@ -377,7 +377,22 @@ export class IntelligentCompletionsController extends BaseAIMonacoEditorControll }), ); - const lintErrorCodeEditsSource = this.injector.get(LintErrorCodeEditsSource, [this.monacoEditor, this.token]); - this.featureDisposable.addDispose(lintErrorCodeEditsSource.mount()); + const codeEditsSourceCollection = this.injector.get(CodeEditsSourceCollection, [ + [LintErrorCodeEditsSource, LineChangeCodeEditsSource], + this.monacoEditor, + ]); + + codeEditsSourceCollection.mount(); + + this.featureDisposable.addDispose( + autorun((reader) => { + const source = codeEditsSourceCollection.codeEditsResult.read(reader); + if (source) { + this.fetchProvider(source); + } + }), + ); + + this.featureDisposable.addDispose(codeEditsSourceCollection); } } diff --git a/packages/ai-native/src/browser/contrib/intelligent-completions/source/base.ts b/packages/ai-native/src/browser/contrib/intelligent-completions/source/base.ts new file mode 100644 index 0000000000..59cf6c8f29 --- /dev/null +++ b/packages/ai-native/src/browser/contrib/intelligent-completions/source/base.ts @@ -0,0 +1,151 @@ +import { Autowired, INJECTOR_TOKEN, Injectable, Injector, Optional } from '@opensumi/di'; +import { + CancellationTokenSource, + Disposable, + IDisposable, + IntelligentCompletionsRegistryToken, + MaybePromise, + uuid, +} from '@opensumi/ide-core-common'; +import { ConstructorOf } from '@opensumi/ide-core-common'; +import { ICodeEditor, IPosition } from '@opensumi/ide-monaco'; +import { DisposableStore } from '@opensumi/monaco-editor-core/esm/vs/base/common/lifecycle'; +import { + autorunDelta, + debouncedObservable, + derived, + disposableObservableValue, + transaction, +} from '@opensumi/monaco-editor-core/esm/vs/base/common/observable'; + +import { ICodeEdit, ICodeEditsContextBean, ICodeEditsResult } from '../index'; +import { IntelligentCompletionsRegistry } from '../intelligent-completions.feature.registry'; + +@Injectable({ multiple: true }) +export abstract class BaseCodeEditsSource extends Disposable { + @Autowired(IntelligentCompletionsRegistryToken) + private readonly intelligentCompletionsRegistry: IntelligentCompletionsRegistry; + + protected abstract doTrigger(...args: any[]): MaybePromise; + + public readonly codeEditsResult = disposableObservableValue(this, undefined); + + public abstract priority: number; + public abstract mount(): IDisposable; + + constructor(@Optional() protected readonly monacoEditor: ICodeEditor) { + super(); + } + + protected cancellationTokenSource = new CancellationTokenSource(); + + protected get model() { + return this.monacoEditor.getModel(); + } + + protected get token() { + return this.cancellationTokenSource.token; + } + + public cancelToken() { + this.cancellationTokenSource.cancel(); + this.cancellationTokenSource = new CancellationTokenSource(); + } + + protected resetCodeEditsResult = derived(this, () => { + transaction((tx) => { + this.codeEditsResult.set(undefined, tx); + }); + }); + + protected async launchProvider(editor: ICodeEditor, position: IPosition, bean: ICodeEditsContextBean): Promise { + const provider = this.intelligentCompletionsRegistry.getCodeEditsProvider(); + if (provider) { + const result = await provider(editor, position, bean, this.token); + + if (result) { + const codeEditsResultValue = new CodeEditsResultValue(result, this); + + transaction((tx) => { + this.codeEditsResult.set(codeEditsResultValue, tx); + }); + } + } + } +} + +export class CodeEditsResultValue extends Disposable { + public readonly uid = uuid(6); + + constructor(private readonly raw: ICodeEditsResult, private readonly source: BaseCodeEditsSource) { + super(); + } + + public get items(): ICodeEdit[] { + return this.raw.items; + } + + public get priority(): number { + return this.source.priority; + } +} + +@Injectable({ multiple: true }) +export class CodeEditsSourceCollection extends Disposable { + @Autowired(INJECTOR_TOKEN) + private readonly injector: Injector; + + public readonly codeEditsResult = disposableObservableValue(this, undefined); + + constructor( + private readonly constructorSources: ConstructorOf[], + private readonly monacoEditor: ICodeEditor, + ) { + super(); + } + + public mount() { + const sources = this.constructorSources.map((source) => this.injector.get(source, [this.monacoEditor])); + + sources.forEach((source) => this.addDispose(source.mount())); + + const store = this.registerDispose(new DisposableStore()); + + // 观察所有 source 的 codeEditsResult + const observerCodeEditsResult = derived((reader) => ({ + codeEditsResults: new Map(sources.map((source) => [source, source.codeEditsResult.read(reader)])), + })); + + this.addDispose( + autorunDelta( + // 这里需要做 debounce 0 处理,将多次连续的事务通知合并为一次 + debouncedObservable(observerCodeEditsResult, 0, store), + ({ lastValue, newValue }) => { + // 只拿最新的订阅值,如果 uid 相同,表示该值没有变化,就不用往下通知了 + const lastSources = sources.filter((source) => { + const newValueResult = newValue?.codeEditsResults.get(source); + const lastValueResult = lastValue?.codeEditsResults?.get(source); + return newValueResult && (!lastValueResult || newValueResult.uid !== lastValueResult.uid); + }); + + let highestPriority = 0; + let currentResult: CodeEditsResultValue | undefined; + + for (const source of lastSources) { + const value = source.codeEditsResult.get(); + + if (value && value.priority > highestPriority) { + highestPriority = value.priority; + currentResult = value; + } + } + + transaction((tx) => { + // 只通知最高优先级的结果 + this.codeEditsResult.set(currentResult, tx); + }); + }, + ), + ); + } +} 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 new file mode 100644 index 0000000000..ed173c53e6 --- /dev/null +++ b/packages/ai-native/src/browser/contrib/intelligent-completions/source/line-change.source.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@opensumi/di'; +import { IDisposable } from '@opensumi/ide-core-common'; +import { ICursorPositionChangedEvent, Position } from '@opensumi/ide-monaco'; + +import { ECodeEditsSource } from '../index'; + +import { BaseCodeEditsSource } from './base'; + +export interface ILineChangeData { + currentLineNumber: number; + preLineNumber?: number; +} + +@Injectable({ multiple: true }) +export class LineChangeCodeEditsSource extends BaseCodeEditsSource { + public priority = 2; + + private prePosition = this.monacoEditor.getPosition(); + + public mount(): IDisposable { + this.addDispose( + this.monacoEditor.onDidChangeCursorPosition((event: ICursorPositionChangedEvent) => { + const currentPosition = event.position; + if (this.prePosition && this.prePosition.lineNumber !== currentPosition.lineNumber) { + this.doTrigger(currentPosition); + this.prePosition = currentPosition; + } + }), + ); + return this; + } + + private lastEditTime: number | null = null; + protected doTrigger(position: Position) { + if (!position) { + return; + } + + // 如果在 60 秒内再次编辑代码,则不触发 + const currentTime = Date.now(); + if (this.lastEditTime && currentTime - this.lastEditTime < 60 * 1000) { + return; + } + + this.lastEditTime = currentTime; + this.launchProvider(this.monacoEditor, position, { + typing: ECodeEditsSource.LineChange, + data: { + preLineNumber: this.prePosition?.lineNumber, + currentLineNumber: position.lineNumber, + }, + }); + } +} diff --git a/packages/ai-native/src/browser/contrib/intelligent-completions/lint-error.source.ts b/packages/ai-native/src/browser/contrib/intelligent-completions/source/lint-error.source.ts similarity index 57% rename from packages/ai-native/src/browser/contrib/intelligent-completions/lint-error.source.ts rename to packages/ai-native/src/browser/contrib/intelligent-completions/source/lint-error.source.ts index 3cdbf88121..a8dbdbc32e 100644 --- a/packages/ai-native/src/browser/contrib/intelligent-completions/lint-error.source.ts +++ b/packages/ai-native/src/browser/contrib/intelligent-completions/source/lint-error.source.ts @@ -1,11 +1,6 @@ -import { Autowired, Injectable, Optional } from '@opensumi/di'; -import { - CancellationToken, - Disposable, - IDisposable, - IntelligentCompletionsRegistryToken, -} from '@opensumi/ide-core-common'; -import { ICodeEditor, ICursorPositionChangedEvent, IPosition, Position } from '@opensumi/ide-monaco'; +import { Autowired, Injectable } from '@opensumi/di'; +import { IDisposable } from '@opensumi/ide-core-common'; +import { ICursorPositionChangedEvent, IPosition, Position } from '@opensumi/ide-monaco'; import { URI } from '@opensumi/ide-monaco/lib/browser/monaco-api'; import { IWorkspaceService } from '@opensumi/ide-workspace'; import { StandaloneServices } from '@opensumi/monaco-editor-core/esm/vs/editor/standalone/browser/standaloneServices'; @@ -16,10 +11,9 @@ import { MarkerSeverity, } from '@opensumi/monaco-editor-core/esm/vs/platform/markers/common/markers'; -import { IntelligentCompletionsController } from './intelligent-completions.controller'; -import { IntelligentCompletionsRegistry } from './intelligent-completions.feature.registry'; +import { ECodeEditsSource } from '../index'; -import { ECodeEditsSource } from '.'; +import { BaseCodeEditsSource } from './base'; export interface ILinterErrorData { relativeWorkspacePath: string; @@ -59,24 +53,12 @@ namespace MarkerErrorData { } @Injectable({ multiple: true }) -export class LintErrorCodeEditsSource extends Disposable { - @Autowired(IntelligentCompletionsRegistryToken) - private readonly intelligentCompletionsRegistry: IntelligentCompletionsRegistry; +export class LintErrorCodeEditsSource extends BaseCodeEditsSource { + public priority = 1; @Autowired(IWorkspaceService) private readonly workspaceService: IWorkspaceService; - private get model() { - return this.monacoEditor.getModel(); - } - - constructor( - @Optional() private readonly monacoEditor: ICodeEditor, - @Optional() private readonly token: CancellationToken, - ) { - super(); - } - public mount(): IDisposable { let prePosition = this.monacoEditor.getPosition(); @@ -93,7 +75,7 @@ export class LintErrorCodeEditsSource extends Disposable { return this; } - private async doTrigger(position: Position) { + protected async doTrigger(position: Position) { if (!this.model) { return; } @@ -105,26 +87,15 @@ export class LintErrorCodeEditsSource extends Disposable { markers = markers.filter((marker) => Math.abs(marker.startLineNumber - position.lineNumber) <= 1); if (markers.length) { - const provider = this.intelligentCompletionsRegistry.getCodeEditsProvider(); - if (provider) { - const relativeWorkspacePath = await this.workspaceService.asRelativePath(resource.path); - const result = await provider( - this.monacoEditor, - position, - { - typing: ECodeEditsSource.LinterErrors, - data: { - relativeWorkspacePath: relativeWorkspacePath?.path ?? resource.path, - errors: markers.map((marker) => MarkerErrorData.toData(marker)), - }, - }, - this.token, - ); + const relativeWorkspacePath = await this.workspaceService.asRelativePath(resource.path); - if (result) { - IntelligentCompletionsController.get(this.monacoEditor)?.fetchProvider(result); - } - } + this.launchProvider(this.monacoEditor, position, { + typing: ECodeEditsSource.LinterErrors, + data: { + relativeWorkspacePath: relativeWorkspacePath?.path ?? resource.path, + errors: markers.map((marker) => MarkerErrorData.toData(marker)), + }, + }); } } }