diff --git a/public/index.html b/public/index.html index ba627094c8..be1e7dda98 100644 --- a/public/index.html +++ b/public/index.html @@ -25,6 +25,7 @@ window.web_version = !'%REACT_APP_BACKEND%'; window.custom_backend = '%NODE_ENV%' === 'development' && '%REACT_APP_BACKEND%'; window.meta_backend = '%REACT_APP_META_BACKEND%' + window.code_assistant_backend = '%REACT_APP_CODE_ASSISTANT_BACKEND%' diff --git a/src/components/CodeAssistantTelemetry/CodeAssistantTelemetry.tsx b/src/components/CodeAssistantTelemetry/CodeAssistantTelemetry.tsx new file mode 100644 index 0000000000..e9cd2a45b5 --- /dev/null +++ b/src/components/CodeAssistantTelemetry/CodeAssistantTelemetry.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +import {useSendOpenTabsMutation} from '../../store/reducers/codeAssist'; +import {selectQueriesHistory} from '../../store/reducers/query/query'; +import {useTypedSelector} from '../../utils/hooks'; + +export function CodeAssistantTelemetry() { + const [sendOpenTabs] = useSendOpenTabsMutation(); + const historyQueries = useTypedSelector(selectQueriesHistory); + + React.useEffect(() => { + if (!historyQueries?.length) { + return; + } + + const tabs = historyQueries.map((query) => ({ + FileName: `query_${query.queryId}.yql`, + Text: query.queryText, + })); + + sendOpenTabs(tabs); + }, [historyQueries, sendOpenTabs]); + + return null; +} diff --git a/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx b/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx index 29cd198ad0..4340005d16 100644 --- a/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx +++ b/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx @@ -6,8 +6,10 @@ import throttle from 'lodash/throttle'; import type Monaco from 'monaco-editor'; import {v4 as uuidv4} from 'uuid'; +import {CodeAssistantTelemetry} from '../../../../components/CodeAssistantTelemetry/CodeAssistantTelemetry'; import {MonacoEditor} from '../../../../components/MonacoEditor/MonacoEditor'; import SplitPane from '../../../../components/SplitPane'; +import {registerCompletionCommands} from '../../../../services/codeCompletion'; import {useTracingLevelOptionAvailable} from '../../../../store/reducers/capabilities/hooks'; import { goToNextQuery, @@ -42,6 +44,7 @@ import { import {useChangedQuerySettings} from '../../../../utils/hooks/useChangedQuerySettings'; import {useLastQueryExecutionSettings} from '../../../../utils/hooks/useLastQueryExecutionSettings'; import {YQL_LANGUAGE_ID} from '../../../../utils/monaco/constats'; +import {getCompletionProvider} from '../../../../utils/monaco/yql/ydb.inlineCompletionProvider'; import {QUERY_ACTIONS} from '../../../../utils/query'; import type {InitialPaneState} from '../../utils/paneVisibilityToggleHelpers'; import { @@ -215,6 +218,14 @@ export default function QueryEditor(props: QueryEditorProps) { contribution.insert(input); } }); + + if (window.api.codeAssistant) { + const provider = getCompletionProvider(); + if (provider) { + registerCompletionCommands(monaco, provider, editor); + } + } + initResizeHandler(editor); initUserPrompt(editor, getLastQueryText); editor.focus(); @@ -323,6 +334,7 @@ export default function QueryEditor(props: QueryEditorProps) { return (
+ {window.api.codeAssistant && } (); React.useEffect(() => { if (previousTenant.current !== tenantName) { - const register = async () => { + const registerSuggestCompletion = async () => { const {registerYQLCompletionItemProvider} = await import( '../../utils/monaco/yql/yql.completionItemProvider' ); registerYQLCompletionItemProvider(tenantName); }; - register().catch(console.error); + + const registerInlineCompletion = async () => { + const {registerInlineCompletionProvider} = await import( + '../../utils/monaco/yql/ydb.inlineCompletionProvider' + ); + if (window.api.codeAssistant) { + registerInlineCompletionProvider(window.api.codeAssistant); + } + }; + + registerSuggestCompletion().catch(console.error); + registerInlineCompletion().catch(console.error); previousTenant.current = tenantName; } }, [tenantName]); diff --git a/src/services/api/codeAssistant.ts b/src/services/api/codeAssistant.ts new file mode 100644 index 0000000000..6858ca5ea4 --- /dev/null +++ b/src/services/api/codeAssistant.ts @@ -0,0 +1,52 @@ +import {codeAssistantBackend as CODE_ASSISTANT_BACKEND} from '../../store'; +import type {PromptFile, Suggestions, TelemetryEvent, TelemetryOpenTabs} from '../codeCompletion'; + +import {BaseYdbAPI} from './base'; + +const ideInfo = { + Ide: 'ydb', + IdeVersion: '1', + PluginFamily: 'ydb', + PluginVersion: '0.2', +}; + +export class CodeAssistAPI extends BaseYdbAPI { + getPath(path: string) { + return `${CODE_ASSISTANT_BACKEND ?? ''}${path}`; + } + + getCodeAssistSuggestions(data: PromptFile[]): Promise { + return this.post( + this.getPath('/code-assist-suggestion'), + { + Files: data, + ContextCreateType: 1, + IdeInfo: ideInfo, + }, + null, + { + concurrentId: 'code-assist-suggestion', + collectRequest: false, + }, + ); + } + + sendCodeAssistTelemetry(data: TelemetryEvent): Promise { + return this.post(this.getPath('/code-assist-telemetry'), data, null, { + concurrentId: 'code-assist-telemetry', + collectRequest: false, + }); + } + + sendCodeAssistOpenTabs(data: TelemetryOpenTabs): Promise { + return this.post( + this.getPath('/code-assist-telemetry'), + {OpenTabs: {Tabs: data, IdeInfo: ideInfo}}, + null, + { + concurrentId: 'code-assist-telemetry', + collectRequest: false, + }, + ); + } +} diff --git a/src/services/api/index.ts b/src/services/api/index.ts index fbd795c2d3..2e76b26cba 100644 --- a/src/services/api/index.ts +++ b/src/services/api/index.ts @@ -1,6 +1,7 @@ import type {AxiosRequestConfig} from 'axios'; import {AuthAPI} from './auth'; +import {CodeAssistAPI} from './codeAssistant'; import {MetaAPI} from './meta'; import {OperationAPI} from './operation'; import {PDiskAPI} from './pdisk'; @@ -22,11 +23,13 @@ export class YdbEmbeddedAPI { vdisk: VDiskAPI; viewer: ViewerAPI; meta?: MetaAPI; + codeAssistant?: CodeAssistAPI; constructor({config, webVersion}: {config: AxiosRequestConfig; webVersion?: boolean}) { this.auth = new AuthAPI({config}); if (webVersion) { this.meta = new MetaAPI({config}); + this.codeAssistant = new CodeAssistAPI({config}); } this.operation = new OperationAPI({config}); this.pdisk = new PDiskAPI({config}); diff --git a/src/services/codeCompletion/CodeCompletionService.ts b/src/services/codeCompletion/CodeCompletionService.ts new file mode 100644 index 0000000000..e6a98f9345 --- /dev/null +++ b/src/services/codeCompletion/CodeCompletionService.ts @@ -0,0 +1,280 @@ +import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; + +import {getPromptFileContent} from './promptContent'; +import type { + CodeCompletionConfig, + DiscardReason, + EnrichedCompletion, + ICodeCompletionAPI, + ICodeCompletionService, + ITelemetryService, + InternalSuggestion, +} from './types'; + +const DEFAULT_CONFIG: Required = { + debounceTime: 200, + textLimits: { + beforeCursor: 8000, + afterCursor: 1000, + }, + telemetry: { + enabled: true, + }, + suggestionCache: { + enabled: true, + }, +}; + +export class CodeCompletionService implements ICodeCompletionService { + private prevSuggestions: InternalSuggestion[] = []; + private timer: number | null = null; + private readonly api: ICodeCompletionAPI; + private readonly telemetry: ITelemetryService; + private readonly config: Required; + + constructor( + api: ICodeCompletionAPI, + telemetry: ITelemetryService, + userConfig?: CodeCompletionConfig, + ) { + this.api = api; + this.telemetry = telemetry; + // Merge user config with defaults, ensuring all properties exist + this.config = { + ...DEFAULT_CONFIG, + ...userConfig, + textLimits: { + ...DEFAULT_CONFIG.textLimits, + ...(userConfig?.textLimits || {}), + }, + telemetry: { + ...DEFAULT_CONFIG.telemetry, + ...(userConfig?.telemetry || {}), + }, + suggestionCache: { + ...DEFAULT_CONFIG.suggestionCache, + ...(userConfig?.suggestionCache || {}), + }, + }; + } + + handleItemDidShow( + _completions: monaco.languages.InlineCompletions, + item: EnrichedCompletion, + ) { + if (!this.config.suggestionCache.enabled) { + return; + } + + for (const suggests of this.prevSuggestions) { + for (const completion of suggests.items) { + if (completion.pristine === item.pristine) { + suggests.shownCount++; + break; + } + } + } + } + + async provideInlineCompletions( + model: monaco.editor.ITextModel, + position: monaco.Position, + _context: monaco.languages.InlineCompletionContext, + _token: monaco.CancellationToken, + ) { + if (this.config.suggestionCache.enabled) { + const cachedCompletions = this.getCachedCompletion(model, position); + if (cachedCompletions.length) { + return {items: cachedCompletions}; + } + } + + while (this.prevSuggestions.length > 0) { + this.dismissCompletion(this.prevSuggestions.pop()); + } + const {suggestions, requestId} = await this.getSuggestions(model, position); + + this.prevSuggestions = [{items: suggestions, shownCount: 0, requestId}]; + return { + items: suggestions, + }; + } + + handlePartialAccept( + _completions: monaco.languages.InlineCompletions, + item: monaco.languages.InlineCompletion, + acceptedLetters: number, + ) { + const {command} = item; + const commandArguments = command?.arguments?.[0] ?? {}; + const {suggestionText, requestId, prevWordLength = 0} = commandArguments; + const cachedSuggestions = this.prevSuggestions.find((el) => { + return el.items.some((item) => item.pristine === suggestionText); + }); + if (requestId && suggestionText && typeof item.insertText === 'string') { + const acceptedText = item.insertText.slice(prevWordLength, acceptedLetters); + if (acceptedText) { + if (cachedSuggestions) { + cachedSuggestions.wasAccepted = true; + } + this.telemetry.sendAcceptTelemetry(requestId, acceptedText); + } + } + } + + handleAccept({requestId, suggestionText}: {requestId: string; suggestionText: string}) { + this.emptyCache(); + this.telemetry.sendAcceptTelemetry(requestId, suggestionText); + } + + commandDiscard( + reason: DiscardReason = 'OnCancel', + editor: monaco.editor.IStandaloneCodeEditor, + ): void { + while (this.prevSuggestions.length > 0) { + this.discardCompletion(reason, this.prevSuggestions.pop()); + } + editor.trigger(undefined, 'editor.action.inlineSuggest.hide', undefined); + } + + emptyCache() { + this.prevSuggestions = []; + } + + freeInlineCompletions(): void { + // This method is required by Monaco's InlineCompletionsProvider interface + // but we don't need to do anything here since we handle cleanup in other methods + } + + private getCachedCompletion( + model: monaco.editor.ITextModel, + position: monaco.Position, + ): EnrichedCompletion[] { + const completions: EnrichedCompletion[] = []; + for (const suggests of this.prevSuggestions) { + for (const completion of suggests.items) { + if (!completion.range) { + continue; + } + if ( + position.lineNumber < completion.range.startLineNumber || + position.column < completion.range.startColumn + ) { + continue; + } + const startCompletionPosition = new monaco.Position( + completion.range.startLineNumber, + completion.range.startColumn, + ); + const startOffset = model.getOffsetAt(startCompletionPosition); + const endOffset = startOffset + completion.insertText.toString().length; + const positionOffset = model.getOffsetAt(position); + if (positionOffset > endOffset) { + continue; + } + + const completionReplaceText = completion.insertText + .toString() + .slice(0, positionOffset - startOffset); + + const newRange = new monaco.Range( + completion.range.startLineNumber, + completion.range.startColumn, + position.lineNumber, + position.column, + ); + const currentReplaceText = model.getValueInRange(newRange); + if (completionReplaceText.toLowerCase() === currentReplaceText.toLowerCase()) { + completions.push({ + insertText: + currentReplaceText + + completion.insertText.toString().slice(positionOffset - startOffset), + range: newRange, + command: completion.command, + pristine: completion.pristine, + }); + } + } + } + return completions; + } + + private async getSuggestions(model: monaco.editor.ITextModel, position: monaco.Position) { + if (this.timer) { + window.clearTimeout(this.timer); + } + await new Promise((r) => { + this.timer = window.setTimeout(r, this.config.debounceTime); + }); + let suggestions: EnrichedCompletion[] = []; + let requestId = ''; + try { + const data = getPromptFileContent(model, position, { + beforeCursor: this.config.textLimits.beforeCursor, + afterCursor: this.config.textLimits.afterCursor, + }); + if (!data) { + return {suggestions: []}; + } + + const codeAssistSuggestions = await this.api.getCodeAssistSuggestions(data); + requestId = codeAssistSuggestions.RequestId; + const {word, startColumn: lastWordStartColumn} = model.getWordUntilPosition(position); + suggestions = codeAssistSuggestions.Suggests.map((el) => { + const suggestionText = el.Text; + const label = word + suggestionText; + return { + label: label, + sortText: 'a', + insertText: label, + pristine: suggestionText, + range: new monaco.Range( + position.lineNumber, + lastWordStartColumn, + position.lineNumber, + position.column, + ), + command: { + id: 'acceptCodeAssistCompletion', + title: '', + arguments: [ + { + requestId, + suggestionText: suggestionText, + prevWordLength: word.length, + }, + ], + }, + }; + }); + } catch (err) {} + return {suggestions, requestId}; + } + + private discardCompletion(reason: DiscardReason, completion?: InternalSuggestion): void { + if (completion === undefined) { + return; + } + const {requestId, items, shownCount} = completion; + if (!requestId || !items.length) { + return; + } + for (const item of items) { + this.telemetry.sendDeclineTelemetry(requestId, item.pristine, reason, shownCount); + } + } + + private dismissCompletion(completion?: InternalSuggestion): void { + if (completion === undefined) { + return; + } + const {requestId, items, shownCount, wasAccepted} = completion; + + if (!requestId || !items.length || !shownCount || wasAccepted) { + return; + } + for (const item of items) { + this.telemetry.sendIgnoreTelemetry(requestId, item.pristine); + } + } +} diff --git a/src/services/codeCompletion/README.md b/src/services/codeCompletion/README.md new file mode 100644 index 0000000000..38528cef00 --- /dev/null +++ b/src/services/codeCompletion/README.md @@ -0,0 +1,301 @@ +# Code Completion Service + +This module provides inline code completion functionality for the YDB Query Editor using Monaco Editor. It implements a sophisticated completion system with telemetry tracking and command integration. + +## Architecture Overview + +The code completion system consists of several key components working together: + +### Core Components + +1. **CodeCompletionService** (`CodeCompletionService.ts`) + + - Implements Monaco's `InlineCompletionsProvider` interface + - Manages completion suggestions and their lifecycle + - Handles caching of suggestions + - Processes user interactions with suggestions (accept/decline/ignore) + +2. **TelemetryService** (`TelemetryService.ts`) + + - Tracks completion usage metrics + - Records acceptance, decline, and ignore events + - Captures timing and interaction data + - Configurable telemetry collection + +3. **PromptContent** (`promptContent.ts`) + + - Handles extraction of code context for suggestions + - Manages text fragments before and after cursor + - Implements text length limits and cursor positioning + - Creates structured prompt data for API requests + +4. **Factory** (`factory.ts`) + + - Creates and configures the completion provider + - Wires together the completion and telemetry services + - Manages configuration defaults and merging + +5. **Command Registration** (`registerCommands.ts`) + - Registers Monaco editor commands for completion actions + - Handles accept/decline completion commands + +### Integration Points + +1. **Monaco Editor Integration** + + - Registers the completion provider with Monaco + - Manages provider lifecycle + - Integrates with YQL language support + +2. **Query Editor Integration** + - Initializes code completion when editor mounts + - Registers completion commands + - Handles user interactions + +## Data Flow + +1. **Prompt Generation** + + ``` + Editor Change -> PromptContent.getPromptFileContent + -> Text Fragments with Cursor Position -> API Request + ``` + +2. **Suggestion Generation** + + ``` + Prompt Data -> CodeCompletionService.provideInlineCompletions + -> API Request -> Suggestions Returned -> Monaco Display + ``` + +3. **Suggestion Acceptance** + + ``` + User Accept -> handleAccept -> TelemetryService.sendAcceptTelemetry + -> API Telemetry Event + ``` + +4. **Suggestion Rejection** + ``` + User Decline -> commandDiscard -> TelemetryService.sendDeclineTelemetry + -> API Telemetry Event + ``` + +## Configuration Options + +The service accepts a configuration object with these options: + +```typescript +interface CodeCompletionConfig { + // Performance settings + debounceTime?: number; // Time in ms to debounce API calls (default: 200) + + // Text limits + textLimits?: { + beforeCursor?: number; // Characters to include before cursor (default: 8000) + afterCursor?: number; // Characters to include after cursor (default: 1000) + }; + + // Telemetry settings + telemetry?: { + enabled?: boolean; // Whether to enable telemetry (default: true) + }; + + // Cache settings + suggestionCache?: { + enabled?: boolean; // Whether to enable suggestion caching (default: true) + }; +} +``` + +## Key Features + +1. **Prompt Generation** + + - Extracts relevant code context around cursor + - Implements smart text truncation (8000 chars before, 1000 after cursor) + - Maintains cursor position information + - Creates structured prompt format for API + +2. **Suggestion Caching** + + - Caches suggestions to reduce API calls + - Tracks suggestion display count + - Manages suggestion lifecycle + - Configurable caching behavior + +3. **Telemetry** + + - Tracks suggestion acceptance rate + - Records user interaction patterns + - Monitors suggestion quality + - Configurable telemetry collection + +4. **Command Integration** + - Keyboard shortcuts for completion actions + - Context menu integration + - Custom commands for completion workflow + +## API Interface + +The completion service requires an API implementation with these methods: + +```typescript +interface ICodeCompletionAPI { + getCodeAssistSuggestions(data: PromptFile[]): Promise; + sendCodeAssistTelemetry(data: TelemetryEvent): Promise; +} + +interface Suggestions { + Suggests: Suggestion[]; + RequestId: string; +} + +interface Suggestion { + Text: string; +} +``` + +## Prompt Format + +The prompt generation creates structured data in this format: + +```typescript +interface PromptFile { + Path: string; + Fragments: PromptFragment[]; + Cursor: PromptPosition; +} + +interface PromptFragment { + Text: string; + Start: PromptPosition; + End: PromptPosition; +} + +interface PromptPosition { + Ln: number; + Col: number; +} +``` + +## Telemetry Events + +The system tracks three types of events: + +1. **Acceptance Events** + + ```typescript + interface AcceptSuggestionEvent { + Accepted: { + RequestId: string; + Timestamp: number; + AcceptedText: string; + ConvertedText: string; + }; + } + ``` + +2. **Discard Events** + + ```typescript + interface DiscardSuggestionEvent { + Discarded: { + RequestId: string; + Timestamp: number; + DiscardReason: 'OnCancel'; + DiscardedText: string; + CacheHitCount: number; + }; + } + ``` + +3. **Ignore Events** + ```typescript + interface IgnoreSuggestionEvent { + Ignored: { + RequestId: string; + Timestamp: number; + IgnoredText: string; + }; + } + ``` + +## Usage Example + +```typescript +import {createCompletionProvider, registerCompletionCommands} from './codeCompletion'; + +// Create API implementation +const api: ICodeCompletionAPI = { + getCodeAssistSuggestions: async (data) => { ... }, + sendCodeAssistTelemetry: async (data) => { ... } +}; + +// Configure the service +const config: CodeCompletionConfig = { + debounceTime: 200, + textLimits: { + beforeCursor: 8000, + afterCursor: 1000 + }, + telemetry: { + enabled: true + }, + suggestionCache: { + enabled: true + } +}; + +// Create provider +const completionProvider = createCompletionProvider(api, config); + +// Register with Monaco +monaco.languages.registerInlineCompletionsProvider('yql', completionProvider); + +// Register commands +registerCompletionCommands(monaco, completionProvider, editor); +``` + +## Best Practices + +1. **Prompt Generation** + + - Consider text limits for optimal performance + - Maintain cursor position accuracy + - Handle edge cases in text extraction + +2. **Error Handling** + + - All API calls should be wrapped in try-catch blocks + - Failed suggestions should not break the editor + - Telemetry failures should be logged but not impact user experience + +3. **Performance** + + - Suggestions are throttled to prevent excessive API calls + - Caching reduces server load + - Completion provider is disposed when not needed + +4. **User Experience** + - Suggestions appear inline with minimal delay + - Clear feedback for acceptance/rejection + - Non-intrusive telemetry collection + +## Contributing + +When modifying the code completion system: + +1. Ensure all telemetry events are properly tracked +2. Maintain backward compatibility with existing API +3. Update tests for new functionality +4. Consider performance implications of changes +5. Document new features or changes +6. Follow text limit guidelines in prompt generation + +## Related Components + +- Monaco Editor Configuration +- YQL Language Support +- Query Editor Implementation +- Code Assistant API Integration diff --git a/src/services/codeCompletion/TelemetryService.ts b/src/services/codeCompletion/TelemetryService.ts new file mode 100644 index 0000000000..53b0b6e334 --- /dev/null +++ b/src/services/codeCompletion/TelemetryService.ts @@ -0,0 +1,69 @@ +import type {CodeCompletionConfig, DiscardReason, ITelemetryService, TelemetryEvent} from './types'; + +export class TelemetryService implements ITelemetryService { + private readonly sendTelemetry: (data: TelemetryEvent) => Promise; + private readonly config: Required>; + + constructor( + sendTelemetry: (data: TelemetryEvent) => Promise, + config: CodeCompletionConfig = {}, + ) { + this.sendTelemetry = sendTelemetry; + this.config = { + enabled: config.telemetry?.enabled ?? true, + }; + } + + sendAcceptTelemetry(requestId: string, acceptedText: string): void { + const data: TelemetryEvent = { + Accepted: { + RequestId: requestId, + Timestamp: Date.now(), + AcceptedText: acceptedText, + ConvertedText: acceptedText, + }, + }; + this.send(data); + } + + sendDeclineTelemetry( + requestId: string, + suggestionText: string, + reason: DiscardReason, + hitCount: number, + ): void { + const data: TelemetryEvent = { + Discarded: { + RequestId: requestId, + Timestamp: Date.now(), + DiscardReason: reason, + DiscardedText: suggestionText, + CacheHitCount: hitCount, + }, + }; + this.send(data); + } + + sendIgnoreTelemetry(requestId: string, suggestionText: string): void { + const data: TelemetryEvent = { + Ignored: { + RequestId: requestId, + Timestamp: Date.now(), + IgnoredText: suggestionText, + }, + }; + this.send(data); + } + + private send(data: TelemetryEvent): void { + if (!this.config.enabled) { + return; + } + + try { + this.sendTelemetry(data); + } catch (e) { + console.error('Failed to send telemetry:', e); + } + } +} diff --git a/src/services/codeCompletion/factory.ts b/src/services/codeCompletion/factory.ts new file mode 100644 index 0000000000..ecd41e5637 --- /dev/null +++ b/src/services/codeCompletion/factory.ts @@ -0,0 +1,54 @@ +import {CodeCompletionService} from './CodeCompletionService'; +import {TelemetryService} from './TelemetryService'; +import type {CodeCompletionConfig, ICodeCompletionAPI} from './types'; + +const DEFAULT_CONFIG: Required = { + debounceTime: 200, + textLimits: { + beforeCursor: 8000, + afterCursor: 1000, + }, + telemetry: { + enabled: true, + }, + suggestionCache: { + enabled: true, + }, +}; + +function mergeWithDefaults(userConfig?: CodeCompletionConfig): Required { + if (!userConfig) { + return DEFAULT_CONFIG; + } + + return { + debounceTime: userConfig.debounceTime ?? DEFAULT_CONFIG.debounceTime, + textLimits: { + beforeCursor: + userConfig.textLimits?.beforeCursor ?? DEFAULT_CONFIG.textLimits.beforeCursor, + afterCursor: + userConfig.textLimits?.afterCursor ?? DEFAULT_CONFIG.textLimits.afterCursor, + }, + telemetry: { + enabled: userConfig.telemetry?.enabled ?? DEFAULT_CONFIG.telemetry.enabled, + }, + suggestionCache: { + enabled: userConfig.suggestionCache?.enabled ?? DEFAULT_CONFIG.suggestionCache.enabled, + }, + }; +} + +export function createCompletionProvider(api: ICodeCompletionAPI, config?: CodeCompletionConfig) { + const mergedConfig = mergeWithDefaults(config); + + const telemetryService = new TelemetryService( + mergedConfig.telemetry.enabled + ? (data) => api.sendCodeAssistTelemetry(data) + : () => Promise.resolve(undefined), + mergedConfig, + ); + + return new CodeCompletionService(api, telemetryService, mergedConfig); +} + +export {DEFAULT_CONFIG}; diff --git a/src/services/codeCompletion/index.ts b/src/services/codeCompletion/index.ts new file mode 100644 index 0000000000..9de68053b6 --- /dev/null +++ b/src/services/codeCompletion/index.ts @@ -0,0 +1,14 @@ +// Export types needed for API and store integration +export type { + ICodeCompletionAPI, + ICodeCompletionService, + CodeCompletionConfig, + PromptFile, + Suggestions, + TelemetryEvent, + TelemetryOpenTabs, +} from './types'; + +// Export functions needed for Monaco editor integration +export {createCompletionProvider} from './factory'; +export {registerCompletionCommands} from './registerCommands'; diff --git a/src/services/codeCompletion/promptContent.ts b/src/services/codeCompletion/promptContent.ts new file mode 100644 index 0000000000..e2fc785907 --- /dev/null +++ b/src/services/codeCompletion/promptContent.ts @@ -0,0 +1,75 @@ +import type Monaco from 'monaco-editor'; +import {v4} from 'uuid'; + +import type {PromptFile} from './types'; + +export interface TextLimits { + beforeCursor: number; + afterCursor: number; +} + +const DEFAULT_LIMITS: Required = { + beforeCursor: 8_000, + afterCursor: 1_000, +}; + +const sessionId = v4(); + +export function getPromptFileContent( + model: Monaco.editor.ITextModel, + position: Monaco.Position, + limits?: Partial, +): PromptFile[] | undefined { + // Merge with defaults to ensure we always have valid numbers + const finalLimits: Required = { + ...DEFAULT_LIMITS, + ...limits, + }; + + const linesContent = model.getLinesContent(); + const prevTextInCurrentLine = linesContent[position.lineNumber - 1].slice( + 0, + position.column - 1, + ); + const postTextInCurrentLine = linesContent[position.lineNumber - 1].slice(position.column - 1); + const prevText = linesContent + .slice(0, position.lineNumber - 1) + .concat([prevTextInCurrentLine]) + .join('\n'); + const postText = [postTextInCurrentLine] + .concat(linesContent.slice(position.lineNumber)) + .join('\n'); + const cursorPostion = {Ln: position.lineNumber, Col: position.column}; + + const fragments = []; + if (prevText) { + fragments.push({ + Text: + prevText.length > finalLimits.beforeCursor + ? prevText.slice(prevText.length - finalLimits.beforeCursor) + : prevText, + Start: {Ln: 1, Col: 1}, + End: cursorPostion, + }); + } + if (postText) { + fragments.push({ + Text: postText.slice(0, finalLimits.afterCursor), + Start: cursorPostion, + End: { + Ln: linesContent.length, + Col: linesContent[linesContent.length - 1].length, + }, + }); + } + + return fragments.length + ? [ + { + Fragments: fragments, + Cursor: cursorPostion, + Path: `${sessionId}/query.yql`, + }, + ] + : undefined; +} diff --git a/src/services/codeCompletion/registerCommands.ts b/src/services/codeCompletion/registerCommands.ts new file mode 100644 index 0000000000..f3299dd685 --- /dev/null +++ b/src/services/codeCompletion/registerCommands.ts @@ -0,0 +1,24 @@ +import type * as monaco from 'monaco-editor'; + +import type {ICodeCompletionService} from './types'; + +export function registerCompletionCommands( + monacoInstance: typeof monaco, + completionService: ICodeCompletionService, + editor: monaco.editor.IStandaloneCodeEditor, +) { + monacoInstance.editor.registerCommand('acceptCodeAssistCompletion', (_accessor, ...args) => { + const data = args[0] ?? {}; + if (!data || typeof data !== 'object') { + return; + } + const {requestId, suggestionText} = data; + if (requestId && suggestionText) { + completionService.handleAccept({requestId, suggestionText}); + } + }); + + monacoInstance.editor.registerCommand('declineCodeAssistCompletion', () => { + completionService.commandDiscard('OnCancel', editor); + }); +} diff --git a/src/services/codeCompletion/types.ts b/src/services/codeCompletion/types.ts new file mode 100644 index 0000000000..251c53f62d --- /dev/null +++ b/src/services/codeCompletion/types.ts @@ -0,0 +1,142 @@ +import type * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; + +type IdeInfo = { + Ide: string; + IdeVersion: string; + PluginFamily: string; + PluginVersion: string; +}; + +export interface Prompt { + Files: PromptFile[]; + ContextCreateType: ContextCreateType; + ForceSuggest?: boolean; + IdeInfo: IdeInfo; +} + +export interface PromptPosition { + Ln: number; + Col: number; +} + +export interface PromptFragment { + Text: string; + Start: PromptPosition; + End: PromptPosition; +} + +export interface PromptFile { + Path: string; + Fragments: PromptFragment[]; + Cursor: PromptPosition; +} + +export type ContextCreateType = 1; + +export interface Suggestions { + Suggests: Suggestion[]; + RequestId: string; +} + +export type DiscardReason = 'OnCancel'; + +export interface Suggestion { + Text: string; +} + +export interface AcceptSuggestionEvent { + Accepted: { + RequestId: string; + Timestamp: number; + AcceptedText: string; + ConvertedText: string; + }; +} +export interface DiscardSuggestionEvent { + Discarded: { + RequestId: string; + Timestamp: number; + DiscardReason: 'OnCancel'; + DiscardedText: string; + CacheHitCount: number; + }; +} +export interface IgnoreSuggestionEvent { + Ignored: { + RequestId: string; + Timestamp: number; + IgnoredText: string; + }; +} + +export type TelemetryEvent = AcceptSuggestionEvent | DiscardSuggestionEvent | IgnoreSuggestionEvent; + +type OpenTab = { + FileName: string; + Text: string; +}; + +export type TelemetryOpenTabs = OpenTab[]; + +export interface ICodeCompletionAPI { + getCodeAssistSuggestions(data: PromptFile[]): Promise; + sendCodeAssistTelemetry(data: TelemetryEvent): Promise; +} + +export interface ITelemetryService { + sendAcceptTelemetry(requestId: string, acceptedText: string): void; + sendDeclineTelemetry( + requestId: string, + suggestionText: string, + reason: DiscardReason, + hitCount: number, + ): void; + sendIgnoreTelemetry(requestId: string, suggestionText: string): void; +} + +export interface EnrichedCompletion extends monaco.languages.InlineCompletion { + pristine: string; +} + +export interface InternalSuggestion { + items: EnrichedCompletion[]; + requestId?: string; + shownCount: number; + wasAccepted?: boolean; +} + +export interface ICodeCompletionService extends monaco.languages.InlineCompletionsProvider { + handleItemDidShow( + completions: monaco.languages.InlineCompletions, + item: EnrichedCompletion, + ): void; + handlePartialAccept( + completions: monaco.languages.InlineCompletions, + item: monaco.languages.InlineCompletion, + acceptedLetters: number, + ): void; + handleAccept(params: {requestId: string; suggestionText: string}): void; + commandDiscard(reason: DiscardReason, editor: monaco.editor.IStandaloneCodeEditor): void; + emptyCache(): void; +} + +export interface CodeCompletionConfig { + // Performance settings + debounceTime?: number; // Time in ms to debounce API calls (default: 200) + + // Text limits + textLimits?: { + beforeCursor?: number; // Characters to include before cursor (default: 8000) + afterCursor?: number; // Characters to include after cursor (default: 1000) + }; + + // Telemetry settings + telemetry?: { + enabled?: boolean; // Whether to enable telemetry (default: true) + }; + + // Cache settings + suggestionCache?: { + enabled?: boolean; // Whether to enable suggestion caching (default: true) + }; +} diff --git a/src/store/configureStore.ts b/src/store/configureStore.ts index afc32d4637..07e80758ec 100644 --- a/src/store/configureStore.ts +++ b/src/store/configureStore.ts @@ -46,6 +46,7 @@ function _configureStore< export const webVersion = window.web_version; export const customBackend = window.custom_backend; export const metaBackend = window.meta_backend; +export const codeAssistantBackend = window.code_assistant_backend; const isSingleClusterMode = `${metaBackend}` === 'undefined'; diff --git a/src/store/index.ts b/src/store/index.ts index 38f228f33c..7b6d3a1768 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -6,6 +6,7 @@ export { customBackend, metaBackend, webVersion, + codeAssistantBackend, } from './configureStore'; export {rootReducer} from './reducers'; diff --git a/src/store/reducers/codeAssist.ts b/src/store/reducers/codeAssist.ts new file mode 100644 index 0000000000..7d33e82942 --- /dev/null +++ b/src/store/reducers/codeAssist.ts @@ -0,0 +1,21 @@ +import type {TelemetryOpenTabs} from '../../services/codeCompletion'; + +import {api} from './api'; + +export const codeAssistApi = api.injectEndpoints({ + endpoints: (build) => ({ + sendOpenTabs: build.mutation({ + queryFn: async (params: TelemetryOpenTabs) => { + try { + const data = await window.api.codeAssistant?.sendCodeAssistOpenTabs(params); + return {data}; + } catch (error) { + return {error}; + } + }, + }), + }), + overrideExisting: 'throw', +}); + +export const {useSendOpenTabsMutation} = codeAssistApi; diff --git a/src/types/window.d.ts b/src/types/window.d.ts index a31c06ad28..236918267b 100644 --- a/src/types/window.d.ts +++ b/src/types/window.d.ts @@ -37,6 +37,7 @@ interface Window { web_version?: boolean; custom_backend?: string; meta_backend?: string; + code_assistant_backend?: string; userSettings?: import('../services/settings').SettingsObject; systemSettings?: import('../services/settings').SettingsObject; diff --git a/src/utils/monaco/yql/ydb.inlineCompletionProvider.ts b/src/utils/monaco/yql/ydb.inlineCompletionProvider.ts new file mode 100644 index 0000000000..82b92b58fb --- /dev/null +++ b/src/utils/monaco/yql/ydb.inlineCompletionProvider.ts @@ -0,0 +1,37 @@ +import * as monaco from 'monaco-editor'; +import {LANGUAGE_ID} from 'monaco-yql-languages/build/yql/yql.contribution'; + +import {createCompletionProvider} from '../../../services/codeCompletion'; +import type { + CodeCompletionConfig, + ICodeCompletionAPI, + ICodeCompletionService, +} from '../../../services/codeCompletion'; + +let inlineProvider: monaco.IDisposable | undefined; + +function disableCodeSuggestions(): void { + if (inlineProvider) { + inlineProvider.dispose(); + } +} + +let completionProviderInstance: ICodeCompletionService | null = null; + +export function getCompletionProvider(): ICodeCompletionService | null { + return completionProviderInstance; +} + +export function registerInlineCompletionProvider( + api: ICodeCompletionAPI, + config?: CodeCompletionConfig, +) { + disableCodeSuggestions(); + + completionProviderInstance = createCompletionProvider(api, config); + + inlineProvider = monaco.languages.registerInlineCompletionsProvider( + LANGUAGE_ID, + completionProviderInstance, + ); +}