Merge branch 're-add-autocomplete' into model-seln-2

This commit is contained in:
Andrew Pareles 2024-12-09 19:12:57 -08:00
commit d8b6b3c306
17 changed files with 950 additions and 12 deletions

View file

@ -123,7 +123,7 @@ protocol.registerSchemesAsPrivileged([
},
{
scheme: 'vscode-file',
privileges: { secure: true, standard: true, supportFetchAPI: true, corsEnabled: true, codeCache: true }
privileges: { secure: true, standard: true, supportFetchAPI: true, corsEnabled: true, codeCache: true, }
}
]);

View file

@ -120,8 +120,6 @@ import { AuxiliaryWindowsMainService } from '../../platform/auxiliaryWindow/elec
import { normalizeNFC } from '../../base/common/normalization.js';
import { ICSSDevelopmentService, CSSDevelopmentService } from '../../platform/cssDev/node/cssDevService.js';
import { ExtensionSignatureVerificationService, IExtensionSignatureVerificationService } from '../../platform/extensionManagement/node/extensionSignatureVerificationService.js';
import { LLMMessageChannel } from '../../platform/void/electron-main/llmMessageChannel.js';
import { IMetricsService } from '../../platform/void/common/metricsService.js';
import { MetricsMainService } from '../../platform/void/electron-main/metricsMainService.js';
@ -1242,13 +1240,6 @@ export class CodeApplication extends Disposable {
mainProcessElectronServer.registerChannel('logger', loggerChannel);
sharedProcessClient.then(client => client.registerChannel('logger', loggerChannel));
// Void
const metricsChannel = ProxyChannel.fromService(accessor.get(IMetricsService), disposables);
mainProcessElectronServer.registerChannel('void-channel-metrics', metricsChannel);
const sendLLMMessageChannel = new LLMMessageChannel(accessor.get(IMetricsService));
mainProcessElectronServer.registerChannel('void-channel-sendLLMMessage', sendLLMMessageChannel);
// Extension Host Debug Broadcasting
const electronExtensionHostDebugBroadcastChannel = new ElectronExtensionHostDebugBroadcastChannel(accessor.get(IWindowsMainService));
mainProcessElectronServer.registerChannel('extensionhostdebugservice', electronExtensionHostDebugBroadcastChannel);

View file

@ -25,6 +25,7 @@
connect-src
'self'
https:
*
;
font-src
'self'

View file

@ -23,6 +23,7 @@
connect-src
'self'
https:
*
;
font-src
'self'

View file

@ -25,6 +25,7 @@
connect-src
'self'
https:
*
;
font-src
'self'

View file

@ -23,6 +23,7 @@
connect-src
'self'
https:
*
;
font-src
'self'

View file

@ -38,6 +38,7 @@
'self'
https:
ws:
*
;
font-src
'self'

View file

@ -37,6 +37,7 @@
'self'
https:
ws:
*
;
font-src
'self'

View file

@ -37,6 +37,7 @@
'self'
https:
ws:
*
;
font-src
'self'

View file

@ -37,6 +37,7 @@
'self'
https:
ws:
*
;
font-src
'self'

View file

@ -25,6 +25,7 @@
connect-src
'self'
https:
*
;
font-src
'self'

View file

@ -159,7 +159,6 @@ export const SidebarChat = () => {
const sendLLMMessageService = useService('sendLLMMessageService')
// state of current message
const [instructions, setInstructions] = useState('') // the user's instructions
const onChangeText = useCallback((newStr: string) => { setInstructions(newStr) }, [setInstructions])

View file

@ -0,0 +1,161 @@
import React, { useState } from 'react';
import { AlertCircle, ChevronDown, ChevronUp, X } from 'lucide-react';
import { getCmdKey } from '../../../getCmdKey.js';
// const opaqueMessage = `\
// Unfortunately, Void can't see the full error. However, you should be able to find more details by pressing ${getCmdKey()}+Shift+P, typing "Toggle Developer Tools", and looking at the console.\n
// This error often means you have an incorrect API key. If you're self-hosting your own server, it might mean your CORS headers are off, and you should make sure your server's response has the header "Access-Control-Allow-Origins" set to "*", or at least allows "vscode-file://vscode-app".`
// if ((error instanceof Error) && (error.cause + '').includes('TypeError: Failed to fetch')) {
// e = error as any
// e['Void Team'] = opaqueMessage
// }
type Details = {
message: string,
name: string,
stack: string | null,
cause: string | null,
code: string | null,
additional: Record<string, any>
}
// Get detailed error information
const getErrorDetails = (error: unknown) => {
let details: Details;
let e: Error & { [other: string]: undefined | any }
// If fetch() fails, it gives an opaque message. We add extra details to the error.
if (error instanceof Error) {
e = error
}
// sometimes error is an object but not an Error
else if (typeof error === 'object') {
e = new Error(`The server didn't give a very useful error message. More details below.`, { cause: JSON.stringify(error) })
}
else {
e = new Error(String(error))
}
// console.log('error display', JSON.stringify(e))
const message = e.message && e.error ?
(e.message + ':\n' + e.error)
: e.message || e.error || JSON.stringify(error)
details = {
name: e.name || 'Error',
message: message,
stack: null, // e.stack is ignored because it's ugly and not very useful
cause: e.cause ? String(e.cause) : null,
code: e.code || null,
additional: {}
}
// Collect any additional properties from the e
for (let prop of Object.getOwnPropertyNames(e).filter((prop) => !Object.keys(details).includes(prop)))
details.additional[prop] = (e as any)[prop]
return details;
};
export const ErrorDisplay = ({
error,
onDismiss = null,
showDismiss = true,
className = ''
}: {
error: Error | object | string,
onDismiss: (() => void) | null,
showDismiss?: boolean,
className?: string
}) => {
const [isExpanded, setIsExpanded] = useState(false);
const details = getErrorDetails(error);
const hasDetails = details.cause || Object.keys(details.additional).length > 0;
return (
<div className={`rounded-lg border border-red-200 bg-red-50 p-4 ${className}`}>
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex gap-3">
<AlertCircle className="h-5 w-5 text-red-500 mt-0.5" />
<div className="flex-1">
<h3 className="font-semibold text-red-800">
{details.name}
</h3>
<p className="text-red-700 mt-1">
{details.message}
</p>
</div>
</div>
<div className="flex gap-2">
{hasDetails && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-red-600 hover:text-red-800 p-1 rounded"
>
{isExpanded ? (
<ChevronUp className="h-5 w-5" />
) : (
<ChevronDown className="h-5 w-5" />
)}
</button>
)}
{showDismiss && onDismiss && (
<button
onClick={onDismiss}
className="text-red-600 hover:text-red-800 p-1 rounded"
>
<X className="h-5 w-5" />
</button>
)}
</div>
</div>
{/* Expandable Details */}
{isExpanded && hasDetails && (
<div className="mt-4 space-y-3 border-t border-red-200 pt-3">
{details.code && (
<div>
<span className="font-semibold text-red-800">Error Code: </span>
<span className="text-red-700">{details.code}</span>
</div>
)}
{details.cause && (
<div>
<span className="font-semibold text-red-800">Cause: </span>
<span className="text-red-700">{details.cause}</span>
</div>
)}
{Object.keys(details.additional).length > 0 && (
<div>
<span className="font-semibold text-red-800">Additional Information:</span>
<pre className="mt-1 text-sm text-red-700 overflow-x-auto whitespace-pre-wrap">
{Object.keys(details.additional).map(key => `${key}:\n${details.additional[key]}`).join('\n')}
</pre>
</div>
)}
{/* {details.stack && (
<div>
<span className="font-semibold text-red-800">Stack Trace:</span>
<pre className="mt-1 text-sm text-red-700 overflow-x-auto whitespace-pre-wrap">
{details.stack}
</pre>
</div>
)} */}
</div>
)}
</div>
);
};

View file

@ -0,0 +1,772 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Glass Devtools, Inc. All rights reserved.
* Void Editor additions licensed under the AGPLv3 License.
*--------------------------------------------------------------------------------------------*/
import { Disposable } from '../../../../base/common/lifecycle.js';
import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js';
import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
import { IVoidConfigStateService } from './registerConfig.js';
import { ITextModel } from '../../../../editor/common/model.js';
import { Position } from '../../../../editor/common/core/position.js';
import { InlineCompletion, InlineCompletionContext } from '../../../../editor/common/languages.js';
import { CancellationToken } from '../../../../base/common/cancellation.js';
import { Range } from '../../../../editor/common/core/range.js';
import { ISendLLMMessageService } from '../../../../platform/void/browser/llmMessageService.js';
import { IEditorService } from '../../../services/editor/common/editorService.js';
import { isCodeEditor } from '../../../../editor/browser/editorBrowser.js';
import { EditorResourceAccessor } from '../../../common/editor.js';
import { IModelService } from '../../../../editor/common/services/model.js';
// The extension this was called from is here - https://github.com/voideditor/void/blob/autocomplete/extensions/void/src/extension/extension.ts
/*
A summary of autotab:
Postprocessing
-one common problem for all models is outputting unbalanced parentheses
we solve this by trimming all extra closing parentheses from the generated string
in future, should make sure parentheses are always balanced
-another problem is completing the middle of a string, eg. "const [x, CURSOR] = useState()"
we complete up to first matchup character
but should instead complete the whole line / block (difficult because of parenthesis accuracy)
-too much info is bad. usually we want to show the user 1 line, and have a preloaded response afterwards
this should happen automatically with caching system
should break preloaded responses into \n\n chunks
Preprocessing
- we don't generate if cursor is at end / beginning of a line (no spaces)
- we generate 1 line if there is text to the right of cursor
- we generate 1 line if variable declaration
- (in many cases want to show 1 line but generate multiple)
State
- cache based on prefix (and do some trimming first)
- when press tab on one line, should have an immediate followup response
to do this, show autocompletes before they're fully finished
- [todo] remove each autotab when accepted
!- [todo] provide type information
Details
-generated results are trimmed up to 1 leading/trailing space
-prefixes are cached up to 1 trailing newline
-
*/
class LRUCache<K, V> {
public items: Map<K, V>;
private keyOrder: K[];
private maxSize: number;
private disposeCallback?: (value: V, key?: K) => void;
constructor(maxSize: number, disposeCallback?: (value: V, key?: K) => void) {
if (maxSize <= 0) throw new Error('Cache size must be greater than 0');
this.items = new Map();
this.keyOrder = [];
this.maxSize = maxSize;
this.disposeCallback = disposeCallback;
}
set(key: K, value: V): void {
// If key exists, remove it from the order list
if (this.items.has(key)) {
this.keyOrder = this.keyOrder.filter(k => k !== key);
}
// If cache is full, remove least recently used item
else if (this.items.size >= this.maxSize) {
const key = this.keyOrder[0];
const value = this.items.get(key);
// Call dispose callback if it exists
if (this.disposeCallback && value !== undefined) {
this.disposeCallback(value, key);
}
this.items.delete(key);
this.keyOrder.shift();
}
// Add new item
this.items.set(key, value);
this.keyOrder.push(key);
}
delete(key: K): boolean {
const value = this.items.get(key);
if (value !== undefined) {
// Call dispose callback if it exists
if (this.disposeCallback) {
this.disposeCallback(value, key);
}
this.items.delete(key);
this.keyOrder = this.keyOrder.filter(k => k !== key);
return true;
}
return false;
}
clear(): void {
// Call dispose callback for all items if it exists
if (this.disposeCallback) {
for (const [key, value] of this.items.entries()) {
this.disposeCallback(value, key);
}
}
this.items.clear();
this.keyOrder = [];
}
get size(): number {
return this.items.size;
}
has(key: K): boolean {
return this.items.has(key);
}
}
type AutocompletionStatus = 'pending' | 'finished' | 'error';
type Autocompletion = {
id: number,
prefix: string,
suffix: string,
startTime: number,
endTime: number | undefined,
status: AutocompletionStatus,
llmPromise: Promise<string> | undefined,
insertText: string,
requestId: string | null,
}
const DEBOUNCE_TIME = 500
const TIMEOUT_TIME = 60000
const MAX_CACHE_SIZE = 20
const MAX_PENDING_REQUESTS = 2
// postprocesses the result
const postprocessResult = (result: string) => {
// trim all whitespace except for a single leading/trailing space
// return result.trim()
const hasLeadingSpace = result.startsWith(' ');
const hasTrailingSpace = result.endsWith(' ');
return (hasLeadingSpace ? ' ' : '')
+ result.trim()
+ (hasTrailingSpace ? ' ' : '');
}
const extractCodeFromResult = (result: string) => {
// Match either:
// 1. ```language\n<code>```
// 2. ```<code>```
const match = result.match(/```(?:\w+\n)?([\s\S]*?)```|```([\s\S]*?)```/);
if (!match) {
return result;
}
// Return whichever group matched (non-empty)
return match[1] ?? match[2] ?? result;
}
// trims the end of the prefix to improve cache hit rate
const removeLeftTabsAndTrimEnd = (s: string): string => {
const trimmedString = s.trimEnd();
const trailingEnd = s.slice(trimmedString.length);
// keep only a single trailing newline
if (trailingEnd.includes('\n')) {
s = trimmedString + '\n';
}
s = s.replace(/^\s+/gm, ''); // remove left tabs
return s;
}
function getStringUpToUnbalancedParenthesis(s: string, prefix: string): string {
const pairs: Record<string, string> = { ')': '(', '}': '{', ']': '[' };
// process all bracets in prefix
let stack: string[] = []
const firstOpenIdx = prefix.search(/[[({]/);
if (firstOpenIdx !== -1) {
const brackets = prefix.slice(firstOpenIdx).split('').filter(c => '()[]{}'.includes(c));
for (const bracket of brackets) {
if (bracket === '(' || bracket === '{' || bracket === '[') {
stack.push(bracket);
} else {
if (stack.length > 0 && stack[stack.length - 1] === pairs[bracket]) {
stack.pop();
} else {
stack.push(bracket);
}
}
}
}
// iterate through each character
for (let i = 0; i < s.length; i++) {
const char = s[i];
if (char === '(' || char === '{' || char === '[') { stack.push(char); }
else if (char === ')' || char === '}' || char === ']') {
if (stack.length === 0 || stack.pop() !== pairs[char]) { return s.substring(0, i); }
}
}
return s;
}
const parenthesisChars = `{}()[]<>\`'"`
// returns the text in the autocompletion to display, assuming the prefix is already matched
const toInlineCompletions = ({ matchInfo, prefix, suffix, autocompletion, position, debug }: { matchInfo: matchInfo, prefix: string, suffix: string, autocompletion: Autocompletion, position: Position, debug?: boolean }): { insertText: string, range: Range }[] => {
const suffixLines = suffix.split('\n')
const prefixLines = prefix.split('\n')
const suffixToTheRightOfCursor = suffixLines[0]
const prefixToTheLeftOfCursor = prefixLines[prefixLines.length - 1]
const generatedMiddle = autocompletion.insertText
let startIdx = matchInfo.startIdx
let endIdx = generatedMiddle.length // exclusive bounds
// const naiveReturnValue = generatedMiddle.slice(startIdx)
// console.log('naiveReturnValue: ', JSON.stringify(naiveReturnValue))
// return [{ insertText: naiveReturnValue, }]
// do postprocessing for better ux
// this is a bit hacky but may change a lot
// if there is space at the start of the completion and user has added it, remove it
const charToLeftOfCursor = prefixToTheLeftOfCursor.slice(-1)[0] || ''
const userHasAddedASpace = charToLeftOfCursor === ' ' || charToLeftOfCursor === '\t'
const rawFirstNonspaceIdx = generatedMiddle.slice(startIdx).search(/[^\t ]/)
if (rawFirstNonspaceIdx > -1 && userHasAddedASpace) {
const firstNonspaceIdx = rawFirstNonspaceIdx + startIdx;
// console.log('p0', startIdx, rawFirstNonspaceIdx)
startIdx = Math.max(startIdx, firstNonspaceIdx)
}
// if user is on a blank line and the generation starts with newline(s), remove them
const numStartingNewlines = generatedMiddle.slice(startIdx).match(/^\n+/)?.[0].length || 0;
if (
!prefixToTheLeftOfCursor.trim()
&& !suffixToTheRightOfCursor.trim()
&& numStartingNewlines > 0
) {
// console.log('p1', numStartingNewlines)
startIdx += numStartingNewlines
}
// if the generated text matches with the suffix on the current line, stop
if (suffixToTheRightOfCursor.trim()) { // completing in the middle of a line
// complete until there is a match
const rawMatchIndex = generatedMiddle.slice(startIdx).lastIndexOf(suffixToTheRightOfCursor.trim()[0])
if (rawMatchIndex > -1) {
// console.log('p2', rawMatchIndex, startIdx, suffixToTheRightOfCursor.trim()[0], 'AAA', generatedMiddle.slice(startIdx))
const matchIdx = rawMatchIndex + startIdx;
const matchChar = generatedMiddle[matchIdx]
if (parenthesisChars.includes(matchChar)) {
endIdx = Math.min(endIdx, matchIdx)
}
}
}
const restOfLineToGenerate = generatedMiddle.slice(startIdx).split('\n')[0] ?? ''
// condition to complete as a single line completion
if (
prefixToTheLeftOfCursor.trim()
&& !suffixToTheRightOfCursor.trim()
&& restOfLineToGenerate.trim()
) {
const rawNewlineIdx = generatedMiddle.slice(startIdx).indexOf('\n')
if (rawNewlineIdx > -1) {
// console.log('p3', startIdx, rawNewlineIdx)
const newlineIdx = rawNewlineIdx + startIdx;
endIdx = Math.min(endIdx, newlineIdx)
}
}
// // if a generated line matches with a suffix line, stop
// if (suffixLines.length > 1) {
// console.log('4')
// const lines = []
// for (const generatedLine of generatedLines) {
// if (suffixLines.slice(0, 10).some(suffixLine =>
// generatedLine.trim() !== '' && suffixLine.trim() !== ''
// && generatedLine.trim().startsWith(suffixLine.trim())
// )) break;
// lines.push(generatedLine)
// }
// endIdx = lines.join('\n').length // this is hacky, remove or refactor in future
// }
// console.log('pFinal', startIdx, endIdx)
let completionStr = generatedMiddle.slice(startIdx, endIdx)
// filter out unbalanced parentheses
completionStr = getStringUpToUnbalancedParenthesis(completionStr, prefix)
// console.log('originalCompletionStr: ', JSON.stringify(generatedMiddle.slice(startIdx)))
// console.log('finalCompletionStr: ', JSON.stringify(completionStr))
let rangeToReplace: Range = new Range(position.lineNumber, position.column, position.lineNumber, position.column)
return [{
insertText: completionStr,
range: rangeToReplace,
}]
}
// returns whether this autocompletion is in the cache
// const doesPrefixMatchAutocompletion = ({ prefix, autocompletion }: { prefix: string, autocompletion: Autocompletion }): boolean => {
// const originalPrefix = autocompletion.prefix
// const generatedMiddle = autocompletion.result
// const originalPrefixTrimmed = trimPrefix(originalPrefix)
// const currentPrefixTrimmed = trimPrefix(prefix)
// if (currentPrefixTrimmed.length < originalPrefixTrimmed.length) {
// return false
// }
// const isMatch = (originalPrefixTrimmed + generatedMiddle).startsWith(currentPrefixTrimmed)
// return isMatch
// }
const getPrefixAndSuffix = (model: ITextModel, position: Position) => {
const fullText = model.getValue();
const cursorOffset = model.getOffsetAt(position)
const prefix = fullText.substring(0, cursorOffset)
const suffix = fullText.substring(cursorOffset)
return { prefix, suffix }
}
const getIndex = (str: string, line: number, char: number) => {
return str.split('\n').slice(0, line).join('\n').length + (line > 0 ? 1 : 0) + char;
}
const getLastLine = (s: string): string => {
const matches = s.match(/[^\n]*$/)
return matches ? matches[0] : ''
}
type matchInfo = {
lineStart: number,
character: number,
startIdx: number,
}
// returns the startIdx of the match if there is a match, or undefined if there is no match
// all results are wrt `autocompletion.result`
const getPrefixAutocompletionMatch = ({ prefix, autocompletion }: { prefix: string, autocompletion: Autocompletion }): matchInfo | undefined => {
const trimmedCurrentPrefix = removeLeftTabsAndTrimEnd(prefix)
const trimmedCompletionPrefix = removeLeftTabsAndTrimEnd(autocompletion.prefix)
const trimmedCompletionMiddle = removeLeftTabsAndTrimEnd(autocompletion.insertText)
// console.log('@result: ', JSON.stringify(autocompletion.insertText))
// console.log('@trimmedCurrentPrefix: ', JSON.stringify(trimmedCurrentPrefix))
// console.log('@trimmedCompletionPrefix: ', JSON.stringify(trimmedCompletionPrefix))
// console.log('@trimmedCompletionMiddle: ', JSON.stringify(trimmedCompletionMiddle))
if (trimmedCurrentPrefix.length < trimmedCompletionPrefix.length) { // user must write text beyond the original prefix at generation time
console.log('@undefined1')
return undefined
}
if ( // check that completion starts with the prefix
!(trimmedCompletionPrefix + trimmedCompletionMiddle)
.startsWith(trimmedCurrentPrefix)
) {
console.log('@undefined2')
return undefined
}
// reverse map to find position wrt `autocompletion.result`
const lineStart =
trimmedCurrentPrefix.split('\n').length -
trimmedCompletionPrefix.split('\n').length;
if (lineStart < 0) {
console.log('@undefined3')
console.error('Error: No line found.');
return undefined;
}
const currentPrefixLine = getLastLine(trimmedCurrentPrefix)
const completionPrefixLine = lineStart === 0 ? getLastLine(trimmedCompletionPrefix) : ''
const completionMiddleLine = autocompletion.insertText.split('\n')[lineStart]
const fullCompletionLine = completionPrefixLine + completionMiddleLine
// console.log('currentPrefixLine', currentPrefixLine)
// console.log('completionPrefixLine', completionPrefixLine)
// console.log('completionMiddleLine', completionMiddleLine)
const charMatchIdx = fullCompletionLine.indexOf(currentPrefixLine)
if (charMatchIdx < 0) {
console.log('@undefined4', charMatchIdx)
console.error('Warning: Found character with negative index. This should never happen.')
return undefined
}
const character = (charMatchIdx +
currentPrefixLine.length
- completionPrefixLine.length
)
const startIdx = getIndex(autocompletion.insertText, lineStart, character)
return {
lineStart,
character,
startIdx,
}
}
const getCompletionOptions = ({ prefix, suffix }: { prefix: string, suffix: string }) => {
const prefixLines = prefix.split('\n')
const suffixLines = suffix.split('\n')
const prefixToLeftOfCursor = prefixLines.slice(-1)[0] ?? ''
const suffixToRightOfCursor = suffixLines[0] ?? ''
// default parameters
let shouldGenerate = true
let stopTokens: string[] = ['\n\n', '\r\n\r\n']
// specific cases
if (suffixToRightOfCursor.trim() !== '') { // typing between something
stopTokens = ['\n', '\r\n']
}
// if (prefixToLeftOfCursor.trim() === '' && suffixToRightOfCursor.trim() === '') { // at an empty line
// stopTokens = ['\n\n', '\r\n\r\n']
// }
if (prefixToLeftOfCursor === '') { // at beginning or end of line
shouldGenerate = false
}
return { shouldGenerate, stopTokens }
}
export interface IAutocompleteService {
readonly _serviceBrand: undefined;
}
export const IAutocompleteService = createDecorator<IAutocompleteService>('AutocompleteService');
export class AutocompleteService extends Disposable implements IAutocompleteService {
_serviceBrand: undefined;
private _autocompletionId: number = 0;
private _autocompletionsOfDocument: { [docUriStr: string]: LRUCache<number, Autocompletion> } = {}
private _lastCompletionTime = 0
private _lastPrefix: string = ''
// used internally by vscode
// fires after every keystroke and returns the completion to show
async _provideInlineCompletionItems(
model: ITextModel,
position: Position,
context: InlineCompletionContext,
token: CancellationToken,
): Promise<InlineCompletion[]> {
const disabled = true
const testMode = false
if (disabled) { return []; }
const docUriStr = model.uri.toString();
const { prefix, suffix } = getPrefixAndSuffix(model, position)
// initialize cache and other variables
// note that whenever an autocompletion is rejected, it is removed from cache
if (!this._autocompletionsOfDocument[docUriStr]) {
this._autocompletionsOfDocument[docUriStr] = new LRUCache<number, Autocompletion>(
MAX_CACHE_SIZE,
(autocompletion: Autocompletion) => {
if (autocompletion.requestId)
this._sendLLMMessageService.abort(autocompletion.requestId)
}
)
}
this._lastPrefix = prefix
// print all pending autocompletions
// let _numPending = 0
// this._autocompletionsOfDocument[docUriStr].items.forEach((a: Autocompletion) => { if (a.status === 'pending') _numPending += 1 })
// console.log('@numPending: ' + _numPending)
// get autocompletion from cache
let cachedAutocompletion: Autocompletion | undefined = undefined
let matchInfo: matchInfo | undefined = undefined
for (const autocompletion of this._autocompletionsOfDocument[docUriStr].items.values()) {
// if the user's change matches up with the generated text
matchInfo = getPrefixAutocompletionMatch({ prefix, autocompletion })
if (matchInfo !== undefined) {
cachedAutocompletion = autocompletion
break;
}
}
// if there is a cached autocompletion, return it
if (cachedAutocompletion && matchInfo) {
// console.log('id: ' + cachedAutocompletion.id)
if (cachedAutocompletion.status === 'finished') {
// console.log('A1')
const inlineCompletions = toInlineCompletions({ matchInfo, autocompletion: cachedAutocompletion, prefix, suffix, position, debug: true })
return inlineCompletions
} else if (cachedAutocompletion.status === 'pending') {
// console.log('A2')
try {
await cachedAutocompletion.llmPromise;
const inlineCompletions = toInlineCompletions({ matchInfo, autocompletion: cachedAutocompletion, prefix, suffix, position })
return inlineCompletions
} catch (e) {
this._autocompletionsOfDocument[docUriStr].delete(cachedAutocompletion.id)
console.error('Error creating autocompletion (1): ' + e)
}
} else if (cachedAutocompletion.status === 'error') {
// console.log('A3')
}
return []
}
// else if no more typing happens, then go forwards with the request
// wait DEBOUNCE_TIME for the user to stop typing
const thisTime = Date.now()
this._lastCompletionTime = thisTime
const didTypingHappenDuringDebounce = await new Promise((resolve, reject) =>
setTimeout(() => {
if (this._lastCompletionTime === thisTime) {
resolve(false)
} else {
resolve(true)
}
}, DEBOUNCE_TIME)
)
// if more typing happened, then do not go forwards with the request
if (didTypingHappenDuringDebounce) {
return []
}
// if there are too many pending requests, cancel the oldest one
let numPending = 0
let oldestPending: Autocompletion | undefined = undefined
for (const autocompletion of this._autocompletionsOfDocument[docUriStr].items.values()) {
if (autocompletion.status === 'pending') {
numPending += 1
if (oldestPending === undefined) {
oldestPending = autocompletion
}
if (numPending >= MAX_PENDING_REQUESTS) {
// cancel the oldest pending request and remove it from cache
this._autocompletionsOfDocument[docUriStr].delete(oldestPending.id)
break
}
}
}
const { shouldGenerate, stopTokens } = getCompletionOptions({ prefix, suffix })
if (!shouldGenerate) return []
if (testMode && this._autocompletionId !== 0) { // TODO remove this
return []
}
// console.log('B')
// create a new autocompletion and add it to cache
const newAutocompletion: Autocompletion = {
id: this._autocompletionId++,
prefix: prefix,
suffix: suffix,
startTime: Date.now(),
endTime: undefined,
status: 'pending',
llmPromise: undefined,
insertText: '',
requestId: null,
}
// set parameters of `newAutocompletion` appropriately
newAutocompletion.llmPromise = new Promise((resolve, reject) => {
const requestId = this._sendLLMMessageService.sendLLMMessage({
logging: { loggingName: 'Autocomplete' },
messages: [],
options: { prefix, suffix, stopTokens, },
onText: async ({ newText, fullText }) => {
newAutocompletion.insertText = fullText
// if generation doesn't match the prefix for the first few tokens generated, reject it
if (!getPrefixAutocompletionMatch({ prefix: this._lastPrefix, autocompletion: newAutocompletion })) {
reject('LLM response did not match user\'s text.')
}
},
onFinalMessage: ({ fullText }) => {
// newAutocompletion.prefix = prefix
// newAutocompletion.suffix = suffix
// newAutocompletion.startTime = Date.now()
newAutocompletion.endTime = Date.now()
// newAutocompletion.abortRef = { current: () => { } }
newAutocompletion.status = 'finished'
// newAutocompletion.promise = undefined
newAutocompletion.insertText = postprocessResult(extractCodeFromResult(fullText))
resolve(newAutocompletion.insertText)
},
onError: ({ error }) => {
newAutocompletion.endTime = Date.now()
newAutocompletion.status = 'error'
reject(error)
},
voidConfig: this._voidConfigStateService.state.voidConfig,
})
newAutocompletion.requestId = requestId
// if the request hasnt resolved in TIMEOUT_TIME seconds, reject it
setTimeout(() => {
if (newAutocompletion.status === 'pending') {
reject('Timeout receiving message to LLM.')
}
}, TIMEOUT_TIME)
})
// add autocompletion to cache
this._autocompletionsOfDocument[docUriStr].set(newAutocompletion.id, newAutocompletion)
// show autocompletion
try {
await newAutocompletion.llmPromise
// console.log('id: ' + newAutocompletion.id)
const matchInfo: matchInfo = { startIdx: 0, lineStart: 0, character: 0 }
const inlineCompletions = toInlineCompletions({ matchInfo, autocompletion: newAutocompletion, prefix, suffix, position })
return inlineCompletions
} catch (e) {
this._autocompletionsOfDocument[docUriStr].delete(newAutocompletion.id)
console.error('Error creating autocompletion (2): ' + e)
return []
}
}
constructor(
@ILanguageFeaturesService private _langFeatureService: ILanguageFeaturesService,
@IVoidConfigStateService private readonly _voidConfigStateService: IVoidConfigStateService,
@ISendLLMMessageService private readonly _sendLLMMessageService: ISendLLMMessageService,
@IEditorService private readonly _editorService: IEditorService,
@IModelService private readonly _modelService: IModelService,
) {
super()
this._langFeatureService.inlineCompletionsProvider.register('*', {
provideInlineCompletions: async (model, position, context, token) => {
const items = await this._provideInlineCompletionItems(model, position, context, token)
// console.log('item: ', items?.[0]?.insertText)
return { items: items, }
},
freeInlineCompletions: (completions) => {
// get the `docUriStr` and the `position` of the cursor
const activePane = this._editorService.activeEditorPane;
if (!activePane) return;
const control = activePane.getControl();
if (!control || !isCodeEditor(control)) return;
const position = control.getPosition();
if (!position) return;
const resource = EditorResourceAccessor.getCanonicalUri(this._editorService.activeEditor);
if (!resource) return;
const model = this._modelService.getModel(resource)
if (!model) return;
const docUriStr = resource.toString();
const { prefix, } = getPrefixAndSuffix(model, position)
if (!this._autocompletionsOfDocument[docUriStr]) return;
// go through cached items and remove matching ones
// autocompletion.prefix + autocompletion.insertedText ~== insertedText
completions.items.forEach(item => {
this._autocompletionsOfDocument[docUriStr].items.forEach((autocompletion: Autocompletion) => {
if (removeLeftTabsAndTrimEnd(prefix)
=== removeLeftTabsAndTrimEnd(autocompletion.prefix + autocompletion.insertText)
) {
this._autocompletionsOfDocument[docUriStr].delete(autocompletion.id);
}
});
});
},
})
}
}
registerSingleton(IAutocompleteService, AutocompleteService, InstantiationType.Eager);

View file

@ -10,6 +10,7 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo
import { URI } from '../../../../base/common/uri.js';
import { Emitter, Event } from '../../../../base/common/event.js';
import { IAutocompleteService } from './registerAutocomplete.js';
// if selectionStr is null, it means just send the whole file
export type CodeSelection = {
@ -99,8 +100,10 @@ class ThreadHistoryService extends Disposable implements IThreadHistoryService {
constructor(
@IStorageService private readonly _storageService: IStorageService,
@IAutocompleteService private readonly _autocomplete: IAutocompleteService,
) {
super()
this._autocomplete
this.state = {
allThreads: this._readAllThreads(),

View file

@ -18,5 +18,8 @@ import './registerSidebar.js'
// register Thread History
import './registerThreads.js'
// register Autocomplete
import './registerAutocomplete.js'
// register css
import './media/void.css'

View file

@ -5,7 +5,7 @@
default-src 'none';
child-src 'self' data: blob:;
script-src 'self' 'unsafe-eval' 'sha256-YVBiNCLDtlDv8TpTuATV/fJ9rcBWIq9O9zBL2ndqAgw=' https: http://localhost:* blob:;
connect-src 'self' https: wss: http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:*;"/>
connect-src 'self' https: wss: http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* *;"/>
</head>
<body>
<script>