From ac1daf2439b3bfd3eac99aff8b7e785486335f5b Mon Sep 17 00:00:00 2001 From: HugoCasa Date: Fri, 27 Jun 2025 10:09:09 +0200 Subject: [PATCH] feat: use FIM for code autocomplete (#6081) * feat: use FIM for code autocomplete * nits --- frontend/package-lock.json | 29 +- frontend/package.json | 5 +- frontend/src/lib/components/Editor.svelte | 76 +-- .../copilot/autocomplete/Autocompletor.ts | 200 ++++++ .../copilot/autocomplete/monaco-adapter.ts | 576 ------------------ .../copilot/autocomplete/request.ts | 99 +-- .../components/copilot/autocomplete/widget.ts | 126 ---- frontend/src/lib/components/copilot/lib.ts | 68 +++ frontend/src/lib/components/copilot/utils.ts | 36 +- .../workspaceSettings/AISettings.svelte | 15 +- 10 files changed, 362 insertions(+), 868 deletions(-) create mode 100644 frontend/src/lib/components/copilot/autocomplete/Autocompletor.ts delete mode 100644 frontend/src/lib/components/copilot/autocomplete/monaco-adapter.ts delete mode 100644 frontend/src/lib/components/copilot/autocomplete/widget.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d1a943c27a..8032766789 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -46,6 +46,7 @@ "hash-sum": "^2.0.0", "highlight.js": "^11.8.0", "idb": "^8.0.2", + "lru-cache": "^11.1.0", "lucide-svelte": "^0.399.0", "minimatch": "^10.0.1", "monaco-editor": "npm:@codingame/monaco-vscode-editor-api@~16.1.1", @@ -7050,6 +7051,20 @@ "node": ">=10" } }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/html-tags": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", @@ -7822,16 +7837,12 @@ } }, "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "peer": true, - "dependencies": { - "yallist": "^4.0.0" - }, + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", + "integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==", + "license": "ISC", "engines": { - "node": ">=10" + "node": "20 || >=22" } }, "node_modules/ltgt": { diff --git a/frontend/package.json b/frontend/package.json index 3aa2c1bcba..8b66f9c213 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -91,9 +91,10 @@ "@redocly/json-to-json-schema": "^0.0.1", "@scalar/openapi-parser": "^0.15.0", "@tanstack/svelte-table": "npm:tanstack-table-8-svelte-5@^0.1", + "@tutorlatin/svelte-tiny-virtual-list": "^3.0.2", "@windmill-labs/svelte-dnd-action": "^0.9.48", - "@xyflow/svelte": "^1.0.0", "@xterm/addon-fit": "^0.10.0", + "@xyflow/svelte": "^1.0.0", "ag-charts-community": "^9.0.1", "ag-charts-enterprise": "^9.0.1", "ag-grid-community": "^31.3.4", @@ -112,6 +113,7 @@ "hash-sum": "^2.0.0", "highlight.js": "^11.8.0", "idb": "^8.0.2", + "lru-cache": "^11.1.0", "lucide-svelte": "^0.399.0", "minimatch": "^10.0.1", "monaco-editor": "npm:@codingame/monaco-vscode-editor-api@~16.1.1", @@ -133,7 +135,6 @@ "svelte-exmarkdown": "^5.0.0", "svelte-infinite-loading": "^1.4.0", "svelte-tiny-virtual-list": "^2.0.5", - "@tutorlatin/svelte-tiny-virtual-list": "^3.0.2", "tailwind-merge": "^1.13.2", "vscode": "npm:@codingame/monaco-vscode-extension-api@~16.1.1", "vscode-languageclient": "~9.0.1", diff --git a/frontend/src/lib/components/Editor.svelte b/frontend/src/lib/components/Editor.svelte index d15b989c70..075dda3bd4 100644 --- a/frontend/src/lib/components/Editor.svelte +++ b/frontend/src/lib/components/Editor.svelte @@ -113,7 +113,7 @@ extToLang } from '$lib/editorUtils' import { workspaceStore } from '$lib/stores' - import { type Preview, ResourceService, UserService } from '$lib/gen' + import { type Preview, ResourceService, type ScriptLang, UserService } from '$lib/gen' import type { Text } from 'yjs' import { initializeVscode, keepModelAroundToAvoidDisposalOfWorkers } from '$lib/components/vscode' @@ -149,7 +149,7 @@ import * as htmllang from '$lib/svelteMonarch' import { conf, language } from '$lib/vueMonarch' - import { Autocompletor } from './copilot/autocomplete/monaco-adapter' + import { Autocompletor } from './copilot/autocomplete/Autocompletor' import { AIChatEditorHandler } from './copilot/chat/monaco-adapter' import GlobalReviewButtons from './copilot/chat/GlobalReviewButtons.svelte' import { writable } from 'svelte/store' @@ -650,79 +650,29 @@ } } - $: $reviewingChanges && autocompletor?.reject() - - let completorDisposable: IDisposable | undefined = undefined let autocompletor: Autocompletor | undefined = undefined - function addSuperCompletor(editor: meditor.IStandaloneCodeEditor) { - try { - if (completorDisposable) { - completorDisposable.dispose() - } - if (!scriptLang) { - throw new Error('No script lang') - } - autocompletor = new Autocompletor(editor, lang, scriptLang) - // last user events (currently disabled): - // let lastTs = Date.now() - // editor.onDidChangeModelContent((e) => { - // const thisTs = Date.now() - // lastTs = thisTs - // setTimeout(() => { - // if (thisTs === lastTs) { - // autocompletor?.savePatch() - // } - // }, 150) - // }) - - completorDisposable = editor.onDidChangeCursorPosition((e) => { - autocompletor?.reject() - if ($reviewingChanges) { - return - } - const position = editor.getPosition() - if (!position) { - return - } - const upToText = editor.getModel()?.getValueInRange({ - startLineNumber: position.lineNumber, - startColumn: 0, - endLineNumber: position.lineNumber, - endColumn: position.column - }) - const lastChar = upToText ? upToText[upToText.length - 1] : '' - if (lastChar && lastChar.match(/[\(\{\s:="',]/)) { - autocompletor?.predict() - } - }) - - editor.onKeyDown((e) => { - if (e.keyCode === KeyCode.Escape) { - autocompletor?.reject() - } else if (e.keyCode === KeyCode.Tab && autocompletor?.hasChanges()) { - e.preventDefault() - e.stopPropagation() - autocompletor?.accept() - autocompletor?.predict() - } - }) - } catch (err) { - console.error('Could not add supercompletor', err) + function addAutoCompletor( + editor: meditor.IStandaloneCodeEditor, + scriptLang: ScriptLang | 'bunnative' | 'jsx' | 'tsx' | 'json' + ) { + if (autocompletor) { + autocompletor.dispose() } + autocompletor = new Autocompletor(editor, scriptLang) } $: $copilotInfo.enabled && - $copilotInfo.codeCompletionModel && $codeCompletionSessionEnabled && + Autocompletor.isProviderModelSupported($copilotInfo.codeCompletionModel) && initialized && editor && scriptLang && - addSuperCompletor(editor) + addAutoCompletor(editor, scriptLang) $: $copilotInfo.enabled && initialized && editor && addChatHandler(editor) - $: !$codeCompletionSessionEnabled && (completorDisposable?.dispose(), autocompletor?.reject()) + $: !$codeCompletionSessionEnabled && autocompletor?.dispose() const outputChannel = { name: 'Language Server Client', @@ -1525,7 +1475,7 @@ disposeMethod && disposeMethod() websocketInterval && clearInterval(websocketInterval) sqlSchemaCompletor && sqlSchemaCompletor.dispose() - completorDisposable && completorDisposable.dispose() + autocompletor && autocompletor.dispose() sqlTypeCompletor && sqlTypeCompletor.dispose() timeoutModel && clearTimeout(timeoutModel) loadTimeout && clearTimeout(loadTimeout) diff --git a/frontend/src/lib/components/copilot/autocomplete/Autocompletor.ts b/frontend/src/lib/components/copilot/autocomplete/Autocompletor.ts new file mode 100644 index 0000000000..3000271a5b --- /dev/null +++ b/frontend/src/lib/components/copilot/autocomplete/Autocompletor.ts @@ -0,0 +1,200 @@ +import type { AIProviderModel, ScriptLang } from '$lib/gen' +import { sleep } from '$lib/utils' +import { Position, type editor as meditor, languages, type IDisposable } from 'monaco-editor' +import { LRUCache } from 'lru-cache' +import { autocompleteRequest } from './request' +import { FIM_MAX_TOKENS } from '../lib' + +type CacheCompletion = { + linePrefix: string + completion: string + column: number +} + +function filterCompletion(completion: string, suffix: string): string | undefined { + const trimmedCompletion = completion.replaceAll('\n', '') + const trimmedSuffix = suffix.slice(0, FIM_MAX_TOKENS).replaceAll('\n', '') + + if (trimmedSuffix.startsWith(trimmedCompletion)) { + console.log('suffix starts with completion', suffix, completion) + return + } + return completion +} + +export class Autocompletor { + #lastTs = Date.now() + #cache: LRUCache = new LRUCache({ + max: 10 + }) + #scriptLang: ScriptLang | 'bunnative' | 'jsx' | 'tsx' | 'json' + #abortController: AbortController = new AbortController() + #completionDisposable: IDisposable + #cursorDisposable: IDisposable + + constructor( + editor: meditor.IStandaloneCodeEditor, + scriptLang: ScriptLang | 'bunnative' | 'jsx' | 'tsx' | 'json' + ) { + this.#scriptLang = scriptLang + + this.#completionDisposable = languages.registerInlineCompletionsProvider( + { pattern: '**' }, + { + provideInlineCompletions: async (model, position, context, token) => { + if ( + token.isCancellationRequested || + model.uri.toString() !== editor.getModel()?.uri.toString() + ) { + return { items: [] } + } + const result = await this.#autocomplete(model, position) + + if (result) { + const completion = filterCompletion(result.completion, result.suffix) + + if (!completion) { + return { items: [] } + } + + let range = { + startLineNumber: position.lineNumber, + startColumn: position.column, + endLineNumber: position.lineNumber, + endColumn: position.column + } + + const multiline = completion.indexOf('\n') !== -1 + if (multiline) { + // if multiline the range should span until the end of the line + range.endColumn = model.getLineMaxColumn(position.lineNumber) + } + + return { + items: [ + { + insertText: completion, + range + } + ] + } + } else { + return { + items: [] + } + } + }, + freeInlineCompletions: () => {} + } + ) + + this.#cursorDisposable = editor.onDidChangeCursorPosition(async (e) => { + if (e.source === 'mouse') { + const model = editor.getModel() + if (model) { + this.#autocomplete(model, e.position) + } + } + }) + } + + static isProviderModelSupported(providerModel: AIProviderModel | undefined) { + return ( + providerModel && + providerModel.provider === 'mistral' && + providerModel.model.startsWith('codestral-') && + !providerModel.model.startsWith('codestral-embed') + ) + } + + dispose() { + this.#completionDisposable.dispose() + this.#cursorDisposable.dispose() + } + + async #autocomplete( + model: meditor.ITextModel, + position: Position + ): Promise<{ completion: string; suffix: string } | undefined> { + const thisTs = Date.now() + this.#lastTs = thisTs + + await sleep(200) + + if (model.isDisposed()) { + return + } + + if (thisTs !== this.#lastTs) { + return + } + + const linePrefix = model.getValueInRange({ + startLineNumber: position.lineNumber, + startColumn: 1, + endLineNumber: position.lineNumber, + endColumn: position.column + }) + + const suffix = model.getValueInRange({ + startLineNumber: position.lineNumber, + startColumn: position.column, + endLineNumber: model.getLineCount(), + endColumn: model.getLineMaxColumn(model.getLineCount()) + }) + + const cachedCompletion = this.#cache.get(position.lineNumber) + if (cachedCompletion) { + if ( + position.column > cachedCompletion.column && + linePrefix.length < cachedCompletion.linePrefix.length + cachedCompletion.completion.length + ) { + const completeLine = cachedCompletion.linePrefix + cachedCompletion.completion + const newLinePrefix = completeLine.substring(0, position.column - 1) + if (newLinePrefix === linePrefix) { + const modifiedCompletion = cachedCompletion.completion.slice( + position.column - cachedCompletion.column + ) + console.debug('autocomplete partial cache hit', modifiedCompletion) + return { completion: modifiedCompletion, suffix } + } + } else if ( + position.column === cachedCompletion.column && + cachedCompletion.linePrefix === linePrefix + ) { + console.debug('autocomplete exact cache hit', cachedCompletion.completion) + return { completion: cachedCompletion.completion, suffix } + } + } + + this.#abortController.abort() + this.#abortController = new AbortController() + const prefix = model.getValueInRange({ + startLineNumber: 1, + startColumn: 1, + endLineNumber: position.lineNumber, + endColumn: position.column + }) + + const completion = await autocompleteRequest( + { + prefix, + suffix, + scriptLang: this.#scriptLang + }, + this.#abortController + ) + + if (!completion) { + return + } + + this.#cache.set(position.lineNumber, { + linePrefix, + completion, + column: position.column + }) + + return { completion, suffix } + } +} diff --git a/frontend/src/lib/components/copilot/autocomplete/monaco-adapter.ts b/frontend/src/lib/components/copilot/autocomplete/monaco-adapter.ts deleted file mode 100644 index 4662a62f8f..0000000000 --- a/frontend/src/lib/components/copilot/autocomplete/monaco-adapter.ts +++ /dev/null @@ -1,576 +0,0 @@ -import { type Change, createTwoFilesPatch, diffLines, diffWordsWithSpace } from 'diff' -import { type editor as meditor } from 'monaco-editor' -import { autocompleteRequest } from './request' -import { sleep } from '$lib/utils' -import { displayVisualChanges, getLines, setGlobalCSS, type VisualChange } from '../shared' -import type { ScriptLang } from '$lib/gen' - -function lineChangesToVisualChanges(changes: Change[], startLineNumber: number) { - let originalLineNumber = startLineNumber - let visualChanges: VisualChange[] = [] - - let removedLines: string[] = [] - - for (const c of changes) { - if (c.removed) { - const lines = getLines(c.value) - originalLineNumber += lines.length - removedLines.push(...lines) - } else if (c.added) { - const newLines = getLines(c.value) - const removedStartLineNumber = originalLineNumber - removedLines.length - let afterLines: string[] = [] - for (const [idx, newLine] of newLines.entries()) { - const originalLine = removedLines[idx] - if (originalLine !== undefined) { - const lineDiff = diffWordsWithSpace(originalLine, newLine) - const firstRemovedChangeIdx = lineDiff.findIndex((c) => c.removed) - if (firstRemovedChangeIdx !== -1 && lineDiff.length > 3) { - let startColumn = 1 - let newLineContent = newLine - const firstChange = lineDiff[0] - if ( - !firstChange.added && - !firstChange.removed && - firstChange.value.trim().length === 0 - ) { - startColumn += firstChange.value.length - newLineContent = newLineContent.slice(firstChange.value.length) - } - visualChanges.push({ - type: 'deleted', - range: { - startLine: removedStartLineNumber + idx, - startColumn, - endLine: removedStartLineNumber + idx, - endColumn: 10000 - } - }) - visualChanges.push({ - type: 'added_inline', - position: { - line: removedStartLineNumber + idx, - column: 10000 - }, - value: newLineContent, - options: { - greenHighlight: true - } - }) - } else { - let col = 1 - let removedChars = 0 - for (const charChange of lineDiff) { - if (charChange.added) { - visualChanges.push({ - type: 'added_inline', - position: { - line: removedStartLineNumber + idx, - column: col - }, - value: charChange.value, - options: { - greenHighlight: removedChars > 0 - } - }) - removedChars = Math.max(0, removedChars - charChange.value.length) - } else if (charChange.removed) { - visualChanges.push({ - type: 'deleted', - range: { - startLine: removedStartLineNumber + idx, - startColumn: col, - endLine: removedStartLineNumber + idx, - endColumn: col + charChange.value.length - } - }) - removedChars += charChange.value.length - col += charChange.value.length - } else { - col += charChange.value.length - removedChars = 0 - } - } - } - } else { - afterLines.push(newLine) - } - } - if (afterLines.length > 0) { - visualChanges.push({ - type: 'added_block', - position: { - afterLineNumber: originalLineNumber - 1 - }, - value: afterLines.join('\n') - }) - } - if (removedLines.length > newLines.length) { - for (let i = 0; i < removedLines.length - newLines.length; i++) { - visualChanges.push({ - type: 'deleted', - range: { - startLine: removedStartLineNumber + newLines.length + i, - startColumn: 0, - endLine: removedStartLineNumber + newLines.length + i, - endColumn: 100000 - } - }) - } - } - removedLines = [] - } else { - if (removedLines.length > 0) { - visualChanges.push({ - type: 'deleted', - range: { - startLine: originalLineNumber - removedLines.length, - startColumn: 0, - endLine: originalLineNumber - 1, - endColumn: 10000 - } - }) - } - originalLineNumber += c.count! - removedLines = [] - } - } - if (removedLines.length > 0) { - visualChanges.push({ - type: 'deleted', - range: { - startLine: originalLineNumber - removedLines.length, - startColumn: 0, - endLine: originalLineNumber - 1, - endColumn: 10000 - } - }) - } - return visualChanges -} - -const MAX_PATCHES = 4 - -export class Autocompletor { - editor: meditor.IStandaloneCodeEditor - language: string - scriptLang: ScriptLang | 'bunnative' | 'jsx' | 'tsx' | 'json' - viewZoneIds: string[] = [] - decorationsCollection: meditor.IEditorDecorationsCollection | undefined = undefined - visualChanges: VisualChange[] = [] - modifiedCode: string = '' - applyZone: - | { - startLineNumber: number - endLineNumber: number - } - | undefined = undefined - lastChangePosition: - | { - lineNumber: number - column: number - } - | undefined = undefined - - abortController: AbortController | undefined = undefined - lastTs = Date.now() - - lastCodeValue: string - patches: string[] = [] - - predictedChange: - | { - position: { - lineNumber: number - column: number - } - distance: number - } - | undefined = undefined - tabWidget: meditor.IContentWidget | undefined = undefined - - constructor( - editor: meditor.IStandaloneCodeEditor, - language: string, - scriptLang: ScriptLang | 'bunnative' | 'jsx' | 'tsx' | 'json' - ) { - this.editor = editor - this.language = language - this.scriptLang = scriptLang - this.lastCodeValue = editor.getModel()?.getValue() || '' - } - - savePatch() { - const currentCode = this.editor.getModel()?.getValue() || '' - const patch = createTwoFilesPatch( - '', - '', - this.lastCodeValue, - currentCode, - undefined, - undefined, - { - context: 1 - } - ) - .split('\n') - .slice(4) - .join('\n') - - this.patches.push(patch) - this.lastCodeValue = currentCode - if (this.patches.length > MAX_PATCHES) { - this.patches.shift() - } - } - - async predict() { - this.reject() - await this.autocomplete() - this.computeNextPosition() - this.displayPrediction() - } - - computeNextPosition() { - if (this.visualChanges.length > 0) { - const position = this.editor.getPosition() - if (!position) { - return - } - - let closestPosition: - | { - lineNumber: number - column: number - } - | undefined = undefined - let closestDistance = Infinity - for (const change of this.visualChanges) { - if (change.type === 'deleted') { - const distance = Math.min( - Math.abs(change.range.startLine - position.lineNumber) + - Math.abs(change.range.startColumn - position.column) / 10000, - Math.abs(change.range.endLine - position.lineNumber) + - Math.abs(change.range.endColumn - position.column) / 10000 - ) - if (distance < closestDistance) { - closestDistance = distance - closestPosition = { - lineNumber: change.range.startLine, - column: change.range.startColumn - } - } - } else if (change.type === 'added_block') { - const distance = Math.abs(change.position.afterLineNumber - position.lineNumber) + 1 - if (distance < closestDistance) { - closestDistance = distance - closestPosition = { - lineNumber: change.position.afterLineNumber, - column: 10000 - } - } - } else if (change.type === 'added_inline') { - const distance = - Math.abs(change.position.line - position.lineNumber) + - Math.abs(change.position.column - position.column) / 10000 - if (distance < closestDistance) { - closestDistance = distance - closestPosition = { - lineNumber: change.position.line, - column: change.position.column - } - } - } - } - this.predictedChange = closestPosition - ? { position: closestPosition, distance: closestDistance } - : undefined - - console.log('predictedChange', this.predictedChange, this.visualChanges) - } - } - - displayPrediction() { - if (this.predictedChange) { - if (this.predictedChange.distance < 4) { - this.predictedChange = undefined - this.displayVisualChanges() - } else { - // display tab icon - const el = document.createElement('div') - el.textContent = 'TAB' - - Object.assign(el.style, { - position: 'relative', - background: '#e7e5e4', - color: 'black', - padding: '4px', - fontSize: '10px', - borderRadius: '4px', - textAlign: 'center', - transform: 'translateX(-50%)', - zIndex: 1000, - opacity: 0.8 - }) - - // Create the arrow (pseudo-element trick doesn't work directly via JS, - // so we create a separate element to act like the arrow) - const arrow = document.createElement('div') - Object.assign(arrow.style, { - content: '""', - position: 'absolute', - top: '-6px', - left: '50%', - transform: 'translateX(-50%)', - width: '0', - height: '0', - borderLeft: '6px solid transparent', - borderRight: '6px solid transparent', - borderBottom: '6px solid #e7e5e4' - }) - - // Add arrow to box - el.appendChild(arrow) - this.tabWidget = { - getId: () => 'tab-widget', - getDomNode: () => el, - getPosition: () => { - if (!this.predictedChange) { - return null - } - return { - position: { - lineNumber: this.predictedChange.position.lineNumber, - column: this.predictedChange.position.column - }, - preference: [2] // below - } - }, - allowEditorOverflow: true - } - this.editor.addContentWidget(this.tabWidget) - } - } - } - - async autocomplete() { - const position = this.editor.getPosition() - if (!position) { - return - } - - const model = this.editor.getModel() - - if (!model) { - return - } - - const thisTs = Date.now() - this.lastTs = thisTs - - await sleep(200) - - if (model.isDisposed()) { - return - } - - if (thisTs !== this.lastTs) { - return - } - - this.abortController?.abort() - this.abortController = new AbortController() - - let modifiableEnd = Math.min(model.getLineCount(), position.lineNumber + 7) - while (true) { - if (modifiableEnd <= position.lineNumber) { - break - } - const line = model.getLineContent(modifiableEnd) - if (line.trim().length > 0) { - break - } - modifiableEnd-- - } - - let modifiableStart = Math.max(1, position.lineNumber - 3) - while (true) { - if (modifiableStart >= modifiableEnd) { - break - } - const line = model.getLineContent(modifiableStart) - if (line.trim().length > 0) { - break - } - modifiableStart++ - } - - const newCursorLineNumber = Math.max(position.lineNumber, modifiableStart) - const newPos = { - lineNumber: newCursorLineNumber, - column: newCursorLineNumber === position.lineNumber ? position.column : 0 - } - - this.applyZone = { - startLineNumber: modifiableStart, - endLineNumber: modifiableEnd - } - - const prefix = model.getValueInRange({ - startLineNumber: 1, - startColumn: 1, - endLineNumber: modifiableStart, - endColumn: 1 - }) - - const suffix = model.getValueInRange({ - startLineNumber: modifiableEnd + 1, - startColumn: 0, - endLineNumber: model.getLineCount(), - endColumn: 10000 - }) - - const modifiablePrefix = model.getValueInRange({ - startLineNumber: modifiableStart, - startColumn: 1, - endLineNumber: newPos.lineNumber, - endColumn: newPos.column - }) - - const modifiableSuffix = model.getValueInRange({ - startLineNumber: newPos.lineNumber, - startColumn: newPos.column, - endLineNumber: modifiableEnd, - endColumn: 10000 - }) - - let returnedCode = await autocompleteRequest( - { - prefix, - modifiablePrefix, - modifiableSuffix, - suffix, - language: this.language, - scriptLang: this.scriptLang, - events: this.patches - }, - this.abortController - ) - - if (!returnedCode) { - return - } - - returnedCode = returnedCode.replace('', '') - - const editableCode = model.getValueInRange({ - startLineNumber: modifiableStart, - startColumn: 1, - endLineNumber: modifiableEnd, - endColumn: 10000 - }) - const numberOfLines = modifiableEnd - modifiableStart + 1 - - let completionLines = getLines(returnedCode) - - let finalCompletionLines: string[] = [] - if (completionLines.length > numberOfLines) { - const nextFirstNonEmptyLine = suffix.split('\n').find((line) => line.trim().length > 8) - if (nextFirstNonEmptyLine) { - for (const line of completionLines) { - if (line === nextFirstNonEmptyLine) { - break - } else { - finalCompletionLines.push(line) - } - } - } else { - finalCompletionLines = completionLines - } - } else { - finalCompletionLines = completionLines - } - this.modifiedCode = finalCompletionLines.join('\n') - - const changedLines = diffLines(editableCode, this.modifiedCode) - - this.visualChanges = lineChangesToVisualChanges(changedLines, modifiableStart) - } - - async displayVisualChanges() { - if (this.visualChanges.length > 0) { - const { collection, ids } = await displayVisualChanges( - 'editor-windmill-autocomplete-style', - this.editor, - this.visualChanges - ) - this.decorationsCollection = collection - this.viewZoneIds = ids - - const lastAddChange = this.visualChanges - .reverse() - .find((c) => c.type === 'added_inline' || c.type === 'added_block') - if (lastAddChange) { - if (lastAddChange.type === 'added_inline') { - this.lastChangePosition = { - lineNumber: lastAddChange.position.line, - column: lastAddChange.position.column + lastAddChange.value.length - } - } else if (lastAddChange.type === 'added_block') { - this.lastChangePosition = { - lineNumber: - lastAddChange.position.afterLineNumber + lastAddChange.value.split('\n').length, - column: 10000 - } - } - } - } - } - - hasChanges() { - return this.modifiedCode.length > 0 - } - - accept() { - if (this.predictedChange) { - this.editor.setPosition(this.predictedChange.position) - } - - if (!this.modifiedCode || !this.applyZone) { - return - } - - this.editor.executeEdits('completion', [ - { - range: { - startLineNumber: this.applyZone.startLineNumber, - startColumn: 1, - endLineNumber: this.applyZone.endLineNumber, - endColumn: 10000 - }, - text: this.modifiedCode - } - ]) - if (this.lastChangePosition) { - this.editor.setPosition(this.lastChangePosition) - } - this.reject() - } - - reject() { - this.abortController?.abort() - this.editor.changeViewZones((acc) => { - for (const id of this.viewZoneIds) { - acc.removeZone(id) - } - this.viewZoneIds = [] - }) - this.decorationsCollection?.clear() - this.modifiedCode = '' - setGlobalCSS('editor-windmill-autocomplete-style', '') - this.predictedChange = undefined - this.tabWidget && this.editor.removeContentWidget(this.tabWidget) - this.tabWidget = undefined - this.visualChanges = [] - } -} diff --git a/frontend/src/lib/components/copilot/autocomplete/request.ts b/frontend/src/lib/components/copilot/autocomplete/request.ts index 9c96f7610b..0ec147b58d 100644 --- a/frontend/src/lib/components/copilot/autocomplete/request.ts +++ b/frontend/src/lib/components/copilot/autocomplete/request.ts @@ -1,113 +1,42 @@ -import { codeCompletionLoading, copilotInfo } from '$lib/stores' +import { copilotInfo } from '$lib/stores' import { get } from 'svelte/store' -import { getNonStreamingCompletion } from '../lib' +import { getFimCompletion } from '../lib' import { getLangContext } from '../chat/script/core' import { type ScriptLang } from '$lib/gen/types.gen' - -const AUTOCOMPLETE_SYSTEM_PROMPT = `You're a code assistant. Your task is to help the user write code by suggesting the next edit for the user. - -As an intelligent code assistant, your role is to analyze what the user has been doing and then to suggest the most likely next modification. - -## Task - -Your task is to rewrite the section of the code I send you to include an edit the user should make. -The tag marks the position of the user's cursor. - -Follow the following criteria. - -### High-level Guidelines - -- Consider the overall intent and direction of the changes -- Take into account what the user has been doing -- Maintain the code style and formatting conventions of the language used in the file -- Your edit suggestions **must** be small and self-contained. Example: if there are two statements that logically need to be added together, suggest them together instead of one by one. - -### Constraints - -- Preserve indentation and braces/parentheses/brackets balance. -- Prefer suggesting actual implementations over suggesting placeholders -- Dont explain the code, only return the complete section with your edits. DO NOT return any code after the tag. -- If there are no useful edits to make, return the the section unmodified, without the tag. -- Never include the tag in the response. -- Never remove line breaks inside the section.` - -const AUTOCOMPLETE_USER_PROMPT = ` -WINDMILL LANGUAGE CONTEXT: -{lang_context} - - -{prefix} -{modifiablePrefix}{modifiableSuffix} - -{suffix} - -Return the EDITABLE_CODE section in the form \`\`\`{language} - -...complete editable code section with your modifications - -\`\`\`` - -function postProcessing(response: string) { - const code = response.match(/\n?(.*?)\n?<\/EDITABLE_CODE>/s)?.[1] - - if (!code) { - throw new Error('No code found in response') - } - return code -} +import { getCommentSymbol } from '../utils' export async function autocompleteRequest( context: { prefix: string - modifiablePrefix: string - modifiableSuffix: string suffix: string - language: string scriptLang: ScriptLang | 'bunnative' | 'jsx' | 'tsx' | 'json' - events: string[] }, abortController: AbortController ) { - codeCompletionLoading.set(true) - const systemPrompt = AUTOCOMPLETE_SYSTEM_PROMPT - const userPrompt = AUTOCOMPLETE_USER_PROMPT.replace( - '{lang_context}', - getLangContext(context.scriptLang) - ) - .replace('{prefix}', context.prefix) - .replace('{modifiablePrefix}', context.modifiablePrefix) - .replace('{modifiableSuffix}', context.modifiableSuffix) - .replace('{suffix}', context.suffix) - .replace('{language}', context.language) - .replace('{events}', context.events.join('\n\n')) + const langContext = getLangContext(context.scriptLang) - const info = get(copilotInfo) + const commentSymbol = getCommentSymbol(context.scriptLang) - const providerModel = info.codeCompletionModel + if (langContext) { + const contextLines = langContext.split('\n') + const commentedContext = contextLines.map((line) => `${commentSymbol} ${line}`).join('\n') + context.prefix = commentedContext + '\n' + context.prefix + } + + const providerModel = get(copilotInfo).codeCompletionModel if (!providerModel) { throw new Error('No code completion model selected') } try { - const completion = await getNonStreamingCompletion( - [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: userPrompt } - ], - abortController, - { - forceModelProvider: providerModel - } - ) + const completion = await getFimCompletion(context.prefix, context.suffix, providerModel, abortController) - return postProcessing(completion) + return completion } catch (err) { if (!abortController.signal.aborted) { console.log('Could not generate autocomplete', err.message) } - } finally { - codeCompletionLoading.set(false) } } diff --git a/frontend/src/lib/components/copilot/autocomplete/widget.ts b/frontend/src/lib/components/copilot/autocomplete/widget.ts deleted file mode 100644 index 389936fa37..0000000000 --- a/frontend/src/lib/components/copilot/autocomplete/widget.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { editor as meditor } from 'monaco-editor' - -/** - * Unused for now but might be useful for alternative completion diff - */ -export class DiffEditorWidget { - editor: any - domNode: HTMLElement - diffContainer: HTMLElement - diffEditor: meditor.IStandaloneDiffEditor - constructor(editor: meditor.IStandaloneCodeEditor, modified: string, lang: string) { - this.editor = editor - this.domNode = document.createElement('div') - - this.domNode.style.backgroundColor = 'var(--vscode-editor-background)' - this.domNode.style.border = '1px solid #ccc' - this.domNode.style.zIndex = '1000' // Make sure it's above other elements - - this.diffContainer = document.createElement('div') - this.diffContainer.style.width = '100%' - this.diffContainer.style.height = '100%' - this.diffContainer.style.padding = '0' - this.domNode.appendChild(this.diffContainer) - - // Create a diff editor inside the widget - this.diffEditor = meditor.createDiffEditor(this.diffContainer, { - readOnly: true, - automaticLayout: true, - lineNumbers: 'off', - renderSideBySide: false, - minimap: { - enabled: false - }, - scrollbar: { - vertical: 'hidden', - horizontal: 'hidden' - }, - scrollBeyondLastLine: false, - folding: false, - glyphMargin: false, - renderOverviewRuler: false, - overviewRulerLanes: 0, - renderIndicators: false, - lineDecorationsWidth: 5, - lightbulb: { - enabled: meditor.ShowLightbulbIconMode.Off - }, - lineNumbersMinChars: 0, - renderMarginRevertIcon: false - }) - - const originalModel = meditor.createModel(editor.getValue() || '', lang) - - const modifiedModel = meditor.createModel(modified, lang) - - this.diffEditor.setModel({ - original: originalModel, - modified: modifiedModel - }) - - function getMaxColumn(model: meditor.ITextModel) { - if (!model) return 0 - - let maxColumn = 0 - const totalLines = model.getLineCount() - - for (let line = 1; line <= totalLines; line++) { - maxColumn = Math.max(maxColumn, model.getLineMaxColumn(line)) - } - - return maxColumn - } - - const maxOriginal = getMaxColumn(originalModel) - const maxModified = getMaxColumn(modifiedModel) - const max = Math.max(maxOriginal, maxModified) - const width = Math.min(max * 8, 600) - this.domNode.style.width = `${width}px` - this.diffEditor.onDidUpdateDiff(() => { - const originalLineCount = originalModel.getLineCount() - - const changes = this.diffEditor.getLineChanges() || [] - - console.log('changes', changes) - - let extraLines = 0 - - console.log('original line count', originalLineCount) - - changes.forEach((change) => { - if (change.modifiedEndLineNumber) { - extraLines += change.modifiedEndLineNumber - change.modifiedStartLineNumber + 1 - } - }) - - const lines = originalLineCount + extraLines - console.log('lines', lines) - this.domNode.style.height = `${lines * 20}px` - }) - // console.log(changes) - // console.log('lineCount1', lineCount1) - // console.log('lineCount2', lineCount2) - } - - layout() { - this.diffEditor.layout() - } - - getId() { - return 'diffEditorWidget' - } - - getDomNode() { - return this.domNode - } - - getPosition() { - return { - position: { - lineNumber: 1, - column: 10000 - }, - preference: [meditor.ContentWidgetPositionPreference.EXACT] - } - } -} diff --git a/frontend/src/lib/components/copilot/lib.ts b/frontend/src/lib/components/copilot/lib.ts index 8b5ef02f5c..a73d74b767 100644 --- a/frontend/src/lib/components/copilot/lib.ts +++ b/frontend/src/lib/components/copilot/lib.ts @@ -2,6 +2,7 @@ import type { AIProvider, AIProviderModel } from '$lib/gen' import { copilotInfo, copilotSessionModel, + workspaceStore, type DBSchema, type GraphqlSchema, type SQLSchema @@ -18,6 +19,7 @@ import { get, type Writable } from 'svelte/store' import { OpenAPI, ResourceService, type Script } from '../../gen' import { EDIT_CONFIG, FIX_CONFIG, GEN_CONFIG } from './prompts' import { formatResourceTypes } from './utils' +import { z } from 'zod' export const SUPPORTED_LANGUAGES = new Set(Object.keys(GEN_CONFIG.prompts)) @@ -519,11 +521,77 @@ export async function getNonStreamingCompletion( dangerouslyAllowBrowser: true }) : workspaceAIClients.getOpenaiClient() + const completion = await openaiClient.chat.completions.create(config, fetchOptions) response = completion.choices?.[0]?.message.content || '' return response } +const mistralFimResponseSchema = z.object({ + choices: z.array( + z.object({ + message: z.object({ + content: z.string().optional() + }), + finish_reason: z.string() + }) + ) +}) + +export const FIM_MAX_TOKENS = 256 +export async function getFimCompletion( + prompt: string, + suffix: string, + providerModel: AIProviderModel, + abortController: AbortController +): Promise { + const fetchOptions: { + signal: AbortSignal + headers: Record + } = { + signal: abortController.signal, + headers: { + 'X-Provider': providerModel.provider + } + } + + const workspace = get(workspaceStore) + + const response = await fetch( + `${location.origin}${OpenAPI.BASE}/w/${workspace}/ai/proxy/fim/completions`, + { + method: 'POST', + body: JSON.stringify({ + model: providerModel.model, + temperature: 0, + prompt, + suffix, + stop: ['\n\n'], + max_tokens: FIM_MAX_TOKENS + }), + ...fetchOptions + } + ) + + const body = await response.json() + const parsedBody = mistralFimResponseSchema.parse(body) + + const choice = parsedBody.choices[0] + + if (choice) { + if (choice.finish_reason === 'length' && choice.message.content) { + // take all the lines before the last + const lines = choice.message.content.split('\n') + const joined = lines.slice(0, -1).join('\n') + return joined + } else { + return choice.message.content || '' + } + } else { + return undefined + } +} + export async function getCompletion( messages: ChatCompletionMessageParam[], abortController: AbortController, diff --git a/frontend/src/lib/components/copilot/utils.ts b/frontend/src/lib/components/copilot/utils.ts index 0317ac2319..2ac122dc4d 100644 --- a/frontend/src/lib/components/copilot/utils.ts +++ b/frontend/src/lib/components/copilot/utils.ts @@ -1,10 +1,44 @@ import type { Schema, SchemaProperty } from '../../common' -import type { ResourceType } from '../../gen' +import type { ResourceType, ScriptLang } from '../../gen' import { capitalize, toCamel } from '$lib/utils' import YAML from 'yaml' +export const getCommentSymbol = ( + lang: ScriptLang | 'bunnative' | 'jsx' | 'tsx' | 'json' +): string => { + switch (lang) { + case 'python3': + case 'go': + case 'bash': + case 'powershell': + case 'graphql': + case 'ansible': + case 'nu': + return '#' + case 'nativets': + case 'bun': + case 'deno': + case 'php': + case 'csharp': + case 'java': + case 'bunnative': + return '//' + case 'rust': + case 'postgresql': + case 'mysql': + case 'bigquery': + case 'snowflake': + case 'mssql': + case 'oracledb': + case 'duckdb': + return '--' + default: + return '//' + } +} + export function compile(schema: Schema) { function rec(x: { [name: string]: SchemaProperty }, root = false) { let res = '{\n' diff --git a/frontend/src/lib/components/workspaceSettings/AISettings.svelte b/frontend/src/lib/components/workspaceSettings/AISettings.svelte index b49f0126b0..ab67b998d0 100644 --- a/frontend/src/lib/components/workspaceSettings/AISettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/AISettings.svelte @@ -235,6 +235,9 @@ {#if Object.keys(aiProviders).length > 0} + {@const autocompleteModels = selectedAiModels.filter( + (m) => m.startsWith('codestral-') && !m.startsWith('codestral-embed') + )}

Settings

@@ -256,21 +259,24 @@ { if (e.detail) { - codeCompletionModel = '' + codeCompletionModel = autocompleteModels[0] ?? '' } else { codeCompletionModel = undefined } }} checked={codeCompletionModel != undefined} + disabled={autocompleteModels.length == 0} options={{ - right: 'Code completion' + right: 'Code completion (Codestral only)', + rightTooltip: + 'We currently only support Mistral Codestral models for code completion.' }} /> {#if codeCompletionModel != undefined} {/if}