diff --git a/package-lock.json b/package-lock.json index e6b03ca2b..2e0a60b27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@hookform/resolvers": "^3.10.0", "@reduxjs/toolkit": "^2.5.0", "@tanstack/react-table": "^8.20.6", + "@ydb-platform/monaco-ghost": "^0.4.0", "axios": "^1.7.9", "axios-retry": "^4.5.0", "colord": "^2.9.3", @@ -5778,7 +5779,6 @@ "version": "18.3.5", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.5.tgz", "integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==", - "dev": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -6415,6 +6415,35 @@ "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "dev": true }, + "node_modules/@ydb-platform/monaco-ghost": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@ydb-platform/monaco-ghost/-/monaco-ghost-0.4.0.tgz", + "integrity": "sha512-yOgfQ7PUIPKmTqpOaKtGLc2gDQAzp3z2jASGLB7IAhC8smdDLoLwtNMz30gKkzU29TnICfp6Eem5fxj6nKqmrQ==", + "license": "Apache-2.0", + "dependencies": { + "uuid": "^9.0.0" + }, + "peerDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "monaco-editor": "^0.52.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + } + }, + "node_modules/@ydb-platform/monaco-ghost/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", diff --git a/package.json b/package.json index 3c8408e1e..76bf7de32 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@hookform/resolvers": "^3.10.0", "@reduxjs/toolkit": "^2.5.0", "@tanstack/react-table": "^8.20.6", + "@ydb-platform/monaco-ghost": "^0.4.0", "axios": "^1.7.9", "axios-retry": "^4.5.0", "colord": "^2.9.3", diff --git a/src/containers/Tenant/Query/QueryEditor/YqlEditor.tsx b/src/containers/Tenant/Query/QueryEditor/YqlEditor.tsx index 281fd3111..ae6fb2223 100644 --- a/src/containers/Tenant/Query/QueryEditor/YqlEditor.tsx +++ b/src/containers/Tenant/Query/QueryEditor/YqlEditor.tsx @@ -1,4 +1,5 @@ import NiceModal from '@ebay/nice-modal-react'; +import {useMonacoGhost} from '@ydb-platform/monaco-ghost'; import throttle from 'lodash/throttle'; import type Monaco from 'monaco-editor'; @@ -22,8 +23,9 @@ import {useUpdateErrorsHighlighting} from '../../../../utils/monaco/highlightErr import {QUERY_ACTIONS} from '../../../../utils/query'; import {SAVE_QUERY_DIALOG} from '../SaveQuery/SaveQuery'; import i18n from '../i18n'; +import {useSavedQueries} from '../utils/useSavedQueries'; -import {useEditorOptions} from './helpers'; +import {useCodeAssist, useEditorOptions} from './helpers'; import {getKeyBindings} from './keybindings'; const CONTEXT_MENU_GROUP_ID = 'navigation'; @@ -44,6 +46,7 @@ export function YqlEditor({ const input = useTypedSelector(selectUserInput); const dispatch = useTypedDispatch(); const historyQueries = useTypedSelector(selectQueriesHistory); + const savedQueries = useSavedQueries(); const editorOptions = useEditorOptions(); const updateErrorsHighlighting = useUpdateErrorsHighlighting(); @@ -68,6 +71,21 @@ export function YqlEditor({ window.ydbEditor = undefined; }; + const codeAssist = useCodeAssist(); + const {registerMonacoGhost} = useMonacoGhost({ + api: { + getCodeAssistSuggestions: codeAssist.getCodeAssistSuggestions, + }, + eventHandlers: { + onCompletionAccept: codeAssist.onCompletionAccept, + onCompletionDecline: codeAssist.onCompletionDecline, + onCompletionIgnore: codeAssist.onCompletionIgnore, + }, + config: { + language: YQL_LANGUAGE_ID, + }, + }); + const editorDidMount = (editor: Monaco.editor.IStandaloneCodeEditor, monaco: typeof Monaco) => { window.ydbEditor = editor; const keybindings = getKeyBindings(monaco); @@ -80,6 +98,20 @@ export function YqlEditor({ contribution.insert(input); } }); + + if (window.api.codeAssist) { + registerMonacoGhost(editor); + codeAssist.prepareUserQueriesCache([ + ...historyQueries.map((query, index) => ({ + name: `query${index}.yql`, + text: query.queryText, + })), + ...savedQueries.map((query) => ({ + name: query.name, + text: query.body, + })), + ]); + } initResizeHandler(editor); initUserPrompt(editor, getLastQueryText); editor.focus(); diff --git a/src/containers/Tenant/Query/QueryEditor/helpers.ts b/src/containers/Tenant/Query/QueryEditor/helpers.ts index 990ded87b..c4ebb5d23 100644 --- a/src/containers/Tenant/Query/QueryEditor/helpers.ts +++ b/src/containers/Tenant/Query/QueryEditor/helpers.ts @@ -1,7 +1,10 @@ import React from 'react'; +import type {AcceptEvent, DeclineEvent, IgnoreEvent, PromptFile} from '@ydb-platform/monaco-ghost'; import type Monaco from 'monaco-editor'; +import {codeAssistApi} from '../../../../store/reducers/codeAssist/codeAssist'; +import type {TelemetryOpenTabs} from '../../../../types/api/codeAssist'; import {AUTOCOMPLETE_ON_ENTER, ENABLE_AUTOCOMPLETE} from '../../../../utils/constants'; import {useSetting} from '../../../../utils/hooks'; @@ -32,3 +35,54 @@ export function useEditorOptions() { return options; } + +export function useCodeAssist() { + const [sendCodeAssistPrompt] = codeAssistApi.useLazyGetCodeAssistSuggestionsQuery(); + const [acceptSuggestion] = codeAssistApi.useAcceptSuggestionMutation(); + const [discardSuggestion] = codeAssistApi.useDiscardSuggestionMutation(); + const [ignoreSuggestion] = codeAssistApi.useIgnoreSuggestionMutation(); + const [sendUserQueriesData] = codeAssistApi.useSendUserQueriesDataMutation(); + + const getCodeAssistSuggestions = React.useCallback( + async (promptFiles: PromptFile[]) => sendCodeAssistPrompt(promptFiles).unwrap(), + [sendCodeAssistPrompt], + ); + + const onCompletionAccept = React.useCallback( + async (event: AcceptEvent) => acceptSuggestion(event).unwrap(), + [acceptSuggestion], + ); + + const onCompletionDecline = React.useCallback( + async (event: DeclineEvent) => discardSuggestion(event).unwrap(), + [discardSuggestion], + ); + + const onCompletionIgnore = React.useCallback( + async (event: IgnoreEvent) => ignoreSuggestion(event).unwrap(), + [ignoreSuggestion], + ); + + const prepareUserQueriesCache = React.useCallback( + async (queries: {text: string; name?: string}[]) => { + const preparedData: TelemetryOpenTabs = queries.map((query, index) => ({ + FileName: query.name || `query${index}.yql`, + Text: query.text, + })); + try { + return sendUserQueriesData(preparedData).unwrap(); + } catch { + return {items: []}; + } + }, + [sendUserQueriesData], + ); + + return { + getCodeAssistSuggestions, + onCompletionAccept, + onCompletionDecline, + onCompletionIgnore, + prepareUserQueriesCache, + }; +} diff --git a/src/services/api/codeAssist.ts b/src/services/api/codeAssist.ts new file mode 100644 index 000000000..61f5d5935 --- /dev/null +++ b/src/services/api/codeAssist.ts @@ -0,0 +1,130 @@ +// IMPORTANT! +// In the future code assist api will be provided to ydb-ui component explicitly by consumer service. +// Current solution is temporary and aimed to satisfy internal puproses. +// It means this whole file will be moved to customer service + +import type {PromptFile, Suggestions} from '@ydb-platform/monaco-ghost'; + +import {codeAssistBackend as CODE_ASSISTANT_BACKEND} from '../../store'; +import type { + CodeAssistSuggestionsFiles, + CodeAssistSuggestionsResponse, + TelemetryEvent, + TelemetryOpenTabs, +} from '../../types/api/codeAssist'; + +import {BaseYdbAPI} from './base'; +const ideInfo = { + Ide: 'ydb', + IdeVersion: '1', + PluginFamily: 'ydb', + PluginVersion: '0.2', +}; + +const limitForTab = 10_000; +const limitBeforeCursor = 8_000; +const limitAfterCursor = 1_000; + +function prepareCodeAssistTabs(tabs: TelemetryOpenTabs): TelemetryOpenTabs { + return tabs.map((tab) => { + const text = tab.Text; + if (text.length > limitForTab) { + return { + ...tab, + Text: text.slice(0, limitForTab), + }; + } + + return tab; + }); +} + +function prepareCodeAssistPrompt(promptFiles: PromptFile[]): CodeAssistSuggestionsFiles { + return promptFiles.map((file) => { + const cursorLine = file.cursorPosition.lineNumber; + const cursorCol = file.cursorPosition.column; + + return { + Fragments: file.fragments.map((fragment) => { + let text = fragment.text; + const isBeforeCursor = + fragment.end.lineNumber < cursorLine || + (fragment.end.lineNumber === cursorLine && fragment.end.column <= cursorCol); + const isAfterCursor = + fragment.start.lineNumber > cursorLine || + (fragment.start.lineNumber === cursorLine && fragment.start.column > cursorCol); + + if (isBeforeCursor) { + text = text.slice(-limitBeforeCursor); + } else if (isAfterCursor) { + text = text.slice(0, limitAfterCursor); + } + + return { + Text: text, + Start: { + Ln: fragment.start.lineNumber, + Col: fragment.start.column, + }, + End: { + Ln: fragment.end.lineNumber, + Col: fragment.end.column, + }, + }; + }), + Cursor: { + Ln: cursorLine, + Col: cursorCol, + }, + Path: `${file.path}.yql`, + }; + }); +} + +export class CodeAssistAPI extends BaseYdbAPI { + getPath(path: string) { + return `${CODE_ASSISTANT_BACKEND ?? ''}${path}`; + } + + async getCodeAssistSuggestions(data: PromptFile[]): Promise { + const request: CodeAssistSuggestionsFiles = prepareCodeAssistPrompt(data); + + const response = await this.post( + this.getPath('/code-assist-suggestion'), + { + Files: request, + ContextCreateType: 1, + IdeInfo: ideInfo, + }, + null, + { + concurrentId: 'code-assist-suggestion', + collectRequest: false, + }, + ); + + return { + items: response.Suggests.map((suggestion) => suggestion.Text), + requestId: response.RequestId, + }; + } + + sendCodeAssistTelemetry(data: TelemetryEvent) { + return this.post('/code-assist-telemetry', data, null, { + concurrentId: 'code-assist-telemetry', + collectRequest: true, + }); + } + + sendCodeAssistOpenTabs(data: TelemetryOpenTabs) { + return this.post( + '/code-assist-telemetry', + {OpenTabs: {Tabs: prepareCodeAssistTabs(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 fbd795c2d..edbd82060 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 './codeAssist'; 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; + codeAssist?: CodeAssistAPI; constructor({config, webVersion}: {config: AxiosRequestConfig; webVersion?: boolean}) { this.auth = new AuthAPI({config}); if (webVersion) { this.meta = new MetaAPI({config}); + this.codeAssist = new CodeAssistAPI({config}); } this.operation = new OperationAPI({config}); this.pdisk = new PDiskAPI({config}); diff --git a/src/store/configureStore.ts b/src/store/configureStore.ts index afc32d463..ee45dfec3 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 codeAssistBackend = window.code_assist_backend; const isSingleClusterMode = `${metaBackend}` === 'undefined'; diff --git a/src/store/index.ts b/src/store/index.ts index 38f228f33..dfd8a8919 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -5,6 +5,7 @@ export { configureStore, customBackend, metaBackend, + codeAssistBackend, webVersion, } from './configureStore'; export {rootReducer} from './reducers'; diff --git a/src/store/reducers/codeAssist/codeAssist.ts b/src/store/reducers/codeAssist/codeAssist.ts new file mode 100644 index 000000000..10e2a4e64 --- /dev/null +++ b/src/store/reducers/codeAssist/codeAssist.ts @@ -0,0 +1,113 @@ +import type { + AcceptEvent, + DeclineEvent, + IgnoreEvent, + PromptFile, + Suggestions, +} from '@ydb-platform/monaco-ghost'; + +import type {TelemetryOpenTabs} from '../../../types/api/codeAssist'; +import {api} from '../api'; + +export const codeAssistApi = api.injectEndpoints({ + endpoints: (builder) => ({ + getCodeAssistSuggestions: builder.query({ + queryFn: async (promptFiles: PromptFile[]) => { + try { + if (window.api.codeAssist) { + const data = + await window.api.codeAssist.getCodeAssistSuggestions(promptFiles); + return {data}; + } else { + throw new Error('Method is not implemented.'); + } + } catch { + return {data: {items: []}}; + } + }, + }), + + acceptSuggestion: builder.mutation({ + queryFn: async (event: AcceptEvent) => { + try { + if (window.api.codeAssist) { + const data = await window.api.codeAssist.sendCodeAssistTelemetry({ + Accepted: { + AcceptedText: event.acceptedText, + ConvertedText: event.acceptedText, + Timestamp: Date.now(), + RequestId: event.requestId, + }, + }); + return {data}; + } else { + throw new Error('Method is not implemented.'); + } + } catch (error) { + return {error}; + } + }, + }), + + discardSuggestion: builder.mutation({ + queryFn: async (event: DeclineEvent) => { + try { + if (window.api.codeAssist) { + const data = await window.api.codeAssist.sendCodeAssistTelemetry({ + Discarded: { + RequestId: event.requestId, + Timestamp: Date.now(), + DiscardReason: 'OnCancel', + DiscardedText: event.suggestionText, + CacheHitCount: event.hitCount, + }, + }); + return {data}; + } else { + throw new Error('Method is not implemented.'); + } + } catch (error) { + return {error}; + } + }, + }), + + ignoreSuggestion: builder.mutation({ + queryFn: async (event: IgnoreEvent) => { + try { + if (window.api.codeAssist) { + const data = await window.api.codeAssist.sendCodeAssistTelemetry({ + Ignored: { + RequestId: event.requestId, + Timestamp: Date.now(), + IgnoredText: event.suggestionText, + }, + }); + return {data}; + } else { + throw new Error('Method is not implemented.'); + } + } catch (error) { + return {error}; + } + }, + }), + + sendUserQueriesData: builder.mutation({ + queryFn: async (userQueries: TelemetryOpenTabs) => { + try { + if (window.api.codeAssist) { + const data = + await window.api.codeAssist.sendCodeAssistOpenTabs(userQueries); + return {data}; + } else { + throw new Error('Method is not implemented.'); + } + } catch (error) { + return {error}; + } + }, + }), + }), + overrideExisting: 'throw', +}); diff --git a/src/types/api/codeAssist.ts b/src/types/api/codeAssist.ts new file mode 100644 index 000000000..21e2048be --- /dev/null +++ b/src/types/api/codeAssist.ts @@ -0,0 +1,57 @@ +interface AcceptSuggestionEvent { + Accepted: { + RequestId: string; + Timestamp: number; + AcceptedText: string; + ConvertedText: string; + }; +} +interface DiscardSuggestionEvent { + Discarded: { + RequestId: string; + Timestamp: number; + DiscardReason: 'OnCancel'; + DiscardedText: string; + CacheHitCount: number; + }; +} +interface IgnoreSuggestionEvent { + Ignored: { + RequestId: string; + Timestamp: number; + IgnoredText: string; + }; +} + +type OpenTab = { + FileName: string; + Text: string; +}; + +interface Position { + Ln: number; + Col: number; +} + +interface Fragment { + Text: string; + Start: Position; + End: Position; +} + +interface File { + Fragments: Fragment[]; + Cursor: Position; + Path: string; +} + +export type CodeAssistSuggestionsFiles = File[]; + +export type CodeAssistSuggestionsResponse = { + RequestId: string; + Suggests: {Text: string}[]; +}; + +export type TelemetryOpenTabs = OpenTab[]; + +export type TelemetryEvent = AcceptSuggestionEvent | DiscardSuggestionEvent | IgnoreSuggestionEvent; diff --git a/src/types/window.d.ts b/src/types/window.d.ts index a31c06ad2..c6855bc41 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_assist_backend?: string; userSettings?: import('../services/settings').SettingsObject; systemSettings?: import('../services/settings').SettingsObject; diff --git a/tests/suites/tenant/TenantPage.ts b/tests/suites/tenant/TenantPage.ts index de43bdf8f..1085c9ae2 100644 --- a/tests/suites/tenant/TenantPage.ts +++ b/tests/suites/tenant/TenantPage.ts @@ -13,12 +13,32 @@ export enum NavigationTabs { export class TenantPage extends PageModel { private navigation: Locator; private radioGroup: Locator; + private diagnosticsContainer: Locator; + private emptyState: Locator; + private emptyStateTitle: Locator; constructor(page: Page) { super(page, tenantPage); this.navigation = page.locator('.ydb-tenant-navigation'); this.radioGroup = this.navigation.locator('.g-radio-button'); + this.diagnosticsContainer = page.locator('.kv-tenant-diagnostics'); + this.emptyState = page.locator('.empty-state'); + this.emptyStateTitle = this.emptyState.locator('.empty-state__title'); + } + + async isDiagnosticsVisible() { + await this.diagnosticsContainer.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + return true; + } + + async isEmptyStateVisible() { + await this.emptyState.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + return true; + } + + async getEmptyStateTitle(): Promise { + return this.emptyStateTitle.innerText(); } async selectNavigationTab(tabName: NavigationTabs) { diff --git a/tests/suites/tenant/initialLoad.test.ts b/tests/suites/tenant/initialLoad.test.ts index 1203a9860..d9f741476 100644 --- a/tests/suites/tenant/initialLoad.test.ts +++ b/tests/suites/tenant/initialLoad.test.ts @@ -15,9 +15,7 @@ test.describe('Tenant initial load', () => { const tenantPage = new TenantPage(page); await tenantPage.goto(pageQueryParams); - await page.waitForTimeout(2000); - - await expect(page.locator('.kv-tenant-diagnostics')).toBeVisible(); + await expect(await tenantPage.isDiagnosticsVisible()).toBeTruthy(); }); test('Tenant diagnostics page is visible when describe returns no data', async ({page}) => { @@ -28,7 +26,7 @@ test.describe('Tenant initial load', () => { const tenantPage = new TenantPage(page); await tenantPage.goto(pageQueryParams); - await expect(page.locator('.kv-tenant-diagnostics')).toBeVisible(); + await expect(await tenantPage.isDiagnosticsVisible()).toBeTruthy(); }); test('Tenant page shows error message when describe returns 401', async ({page}) => { @@ -39,10 +37,8 @@ test.describe('Tenant initial load', () => { const tenantPage = new TenantPage(page); await tenantPage.goto(pageQueryParams); - await page.waitForTimeout(2000); - - await expect(page.locator('.empty-state')).toBeVisible(); - await expect(page.locator('.empty-state__title')).toHaveText('Access denied'); + await expect(await tenantPage.isEmptyStateVisible()).toBeTruthy(); + await expect(await tenantPage.getEmptyStateTitle()).toBe('Access denied'); }); test('Tenant page shows error message when describe returns 403', async ({page}) => { @@ -53,7 +49,7 @@ test.describe('Tenant initial load', () => { const tenantPage = new TenantPage(page); await tenantPage.goto(pageQueryParams); - await expect(page.locator('.empty-state')).toBeVisible(); - await expect(page.locator('.empty-state__title')).toHaveText('Access denied'); + await expect(await tenantPage.isEmptyStateVisible()).toBeTruthy(); + await expect(await tenantPage.getEmptyStateTitle()).toBe('Access denied'); }); });