feat: use FIM for code autocomplete (#6081)

* feat: use FIM for code autocomplete

* nits
This commit is contained in:
HugoCasa
2025-06-27 08:09:09 +00:00
committed by GitHub
parent 6bb9d98024
commit ac1daf2439
10 changed files with 362 additions and 868 deletions
+20 -9
View File
@@ -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": {
+3 -2
View File
@@ -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",
+13 -63
View File
@@ -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)
@@ -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<number, CacheCompletion> = 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 }
}
}
@@ -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('<CURSOR>', '')
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 = []
}
}
@@ -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 <EDITABLE_CODE> section of the code I send you to include an edit the user should make.
The <CURSOR> 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 <EDITABLE_CODE> section with your edits. DO NOT return any code after the <EDITABLE_CODE> tag.
- If there are no useful edits to make, return the the <EDITABLE_CODE> section unmodified, without the <CURSOR> tag.
- Never include the <CURSOR> tag in the response.
- Never remove line breaks inside the <EDITABLE_CODE> section.`
const AUTOCOMPLETE_USER_PROMPT = `
WINDMILL LANGUAGE CONTEXT:
{lang_context}
<CODE>
{prefix}<EDITABLE_CODE>
{modifiablePrefix}<CURSOR>{modifiableSuffix}
</EDITABLE_CODE>
{suffix}</CODE>
Return the EDITABLE_CODE section in the form \`\`\`{language}
<EDITABLE_CODE>
...complete editable code section with your modifications
</EDITABLE_CODE>
\`\`\``
function postProcessing(response: string) {
const code = response.match(/<EDITABLE_CODE>\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)
}
}
@@ -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]
}
}
}
@@ -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<string | undefined> {
const fetchOptions: {
signal: AbortSignal
headers: Record<string, string>
} = {
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,
+35 -1
View File
@@ -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'
@@ -235,6 +235,9 @@
</div>
{#if Object.keys(aiProviders).length > 0}
{@const autocompleteModels = selectedAiModels.filter(
(m) => m.startsWith('codestral-') && !m.startsWith('codestral-embed')
)}
<div class="flex flex-col gap-2">
<p class="font-semibold">Settings</p>
<div class="flex flex-col gap-4">
@@ -256,21 +259,24 @@
<Toggle
on:change={(e) => {
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}
<Label label="Code completion model">
<ArgEnum
enum_={selectedAiModels}
enum_={autocompleteModels}
bind:value={codeCompletionModel}
disabled={false}
autofocus={false}
@@ -278,9 +284,6 @@
valid={true}
create={false}
/>
<p class="text-xs mt-2">
We highly recommend using Mistral's Codestral model for code completion.
</p>
</Label>
{/if}
</div>