diff --git a/package-lock.json b/package-lock.json index bc0458914c7..d29248c296a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,7 +56,7 @@ "node-pty": "1.1.0-beta21", "ollama": "^0.5.11", "open": "^8.4.2", - "openai": "^4.76.1", + "openai": "^4.85.4", "posthog-node": "^4.3.1", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -17079,9 +17079,10 @@ } }, "node_modules/openai": { - "version": "4.77.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-4.77.0.tgz", - "integrity": "sha512-WWacavtns/7pCUkOWvQIjyOfcdr9X+9n9Vvb0zFeKVDAqwCMDHB+iSr24SVaBAhplvSG6JrRXFpcNM9gWhOGIw==", + "version": "4.85.4", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.85.4.tgz", + "integrity": "sha512-Nki51PBSu+Aryo7WKbdXvfm0X/iKkQS2fq3O0Uqb/O3b4exOZFid2te1BZ52bbO5UwxQZ5eeHJDCTqtrJLPw0w==", + "license": "Apache-2.0", "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", @@ -17095,9 +17096,13 @@ "openai": "bin/cli" }, "peerDependencies": { + "ws": "^8.18.0", "zod": "^3.23.8" }, "peerDependenciesMeta": { + "ws": { + "optional": true + }, "zod": { "optional": true } diff --git a/package.json b/package.json index a4ee38bb463..b99ca9dcad2 100644 --- a/package.json +++ b/package.json @@ -124,7 +124,7 @@ "node-pty": "1.1.0-beta21", "ollama": "^0.5.11", "open": "^8.4.2", - "openai": "^4.76.1", + "openai": "^4.85.4", "posthog-node": "^4.3.1", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/src/vs/workbench/browser/parts/editor/editorGroupWatermark.ts b/src/vs/workbench/browser/parts/editor/editorGroupWatermark.ts index 42f4a7a76ac..4dce7e6c86b 100644 --- a/src/vs/workbench/browser/parts/editor/editorGroupWatermark.ts +++ b/src/vs/workbench/browser/parts/editor/editorGroupWatermark.ts @@ -270,7 +270,7 @@ export class EditorGroupWatermark extends Disposable { const keys3 = this.keybindingService.lookupKeybinding('workbench.action.openGlobalKeybindings'); const button3 = append(recentsBox, $('button')); - button3.textContent = 'Void Settings' + button3.textContent = `Void Settings` button3.style.display = 'block' button3.style.marginLeft = 'auto' button3.style.marginRight = 'auto' diff --git a/src/vs/workbench/contrib/void/browser/MarkerCheckService.ts b/src/vs/workbench/contrib/void/browser/MarkerCheckService.ts index f41c05137c1..4ea36c4c951 100644 --- a/src/vs/workbench/contrib/void/browser/MarkerCheckService.ts +++ b/src/vs/workbench/contrib/void/browser/MarkerCheckService.ts @@ -12,6 +12,7 @@ import { ITextModelService } from '../../../../editor/common/services/resolverSe import { Range } from '../../../../editor/common/core/range.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { CodeActionContext, CodeActionTriggerType } from '../../../../editor/common/languages.js'; +import { URI } from '../../../../base/common/uri.js'; export interface IMarkerCheckService { readonly _serviceBrand: undefined; @@ -99,6 +100,21 @@ class MarkerCheckService extends Disposable implements IMarkerCheckService { } + + + fixErrorsInFiles(uris: URI[], contextSoFar: []) { + // const allMarkers = this._markerService.read(); + + + // check errors in files + + + // give LLM errors in files + + + + } + // private _onMarkersChanged = (changedResources: readonly URI[]): void => { // for (const resource of changedResources) { // const markers = this._markerService.read({ resource }); diff --git a/src/vs/workbench/contrib/void/browser/autocompleteService.ts b/src/vs/workbench/contrib/void/browser/autocompleteService.ts index aa8902f36e1..f9cbec7b322 100644 --- a/src/vs/workbench/contrib/void/browser/autocompleteService.ts +++ b/src/vs/workbench/contrib/void/browser/autocompleteService.ts @@ -16,9 +16,9 @@ import { isCodeEditor } from '../../../../editor/browser/editorBrowser.js'; import { EditorResourceAccessor } from '../../../common/editor.js'; import { IModelService } from '../../../../editor/common/services/model.js'; import { extractCodeFromRegular } from './helpers/extractCodeFromResult.js'; -import { isWindows } from '../../../../base/common/platform.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; import { ILLMMessageService } from '../common/llmMessageService.js'; +import { _ln, allLinebreakSymbols } from '../common/voidFileService.js'; // import { IContextGatheringService } from './contextGatheringService.js'; // The extension this was called from is here - https://github.com/voideditor/void/blob/autocomplete/extensions/void/src/extension/extension.ts @@ -415,9 +415,6 @@ const toInlineCompletions = ({ autocompletionMatchup, autocompletion, prefixAndS // } -const allLinebreakSymbols = ['\r\n', '\n'] -const _ln = isWindows ? allLinebreakSymbols[0] : allLinebreakSymbols[1] - type PrefixAndSuffixInfo = { prefix: string, suffix: string, prefixLines: string[], suffixLines: string[], prefixToTheLeftOfCursor: string, suffixToTheRightOfCursor: string } const getPrefixAndSuffixInfo = (model: ITextModel, position: Position): PrefixAndSuffixInfo => { @@ -798,26 +795,27 @@ export class AutocompleteService extends Disposable implements IAutocompleteServ }, useProviderFor: 'Autocomplete', logging: { loggingName: 'Autocomplete' }, - onText: async ({ fullText, newText }) => { - - newAutocompletion.insertText = fullText - - // count newlines in newText - const numNewlines = newText.match(/\n|\r\n/g)?.length || 0 - newAutocompletion._newlineCount += numNewlines - - // if too many newlines, resolve up to last newline - if (newAutocompletion._newlineCount > 10) { - const lastNewlinePos = fullText.lastIndexOf('\n') - newAutocompletion.insertText = fullText.substring(0, lastNewlinePos) - resolve(newAutocompletion.insertText) - return - } - - // if (!getAutocompletionMatchup({ prefix: this._lastPrefix, autocompletion: newAutocompletion })) { - // reject('LLM response did not match user\'s text.') - // } - }, + onText: () => { }, // unused in FIMMessage + // onText: async ({ fullText, newText }) => { + + // newAutocompletion.insertText = fullText + + // // count newlines in newText + // const numNewlines = newText.match(/\n|\r\n/g)?.length || 0 + // newAutocompletion._newlineCount += numNewlines + + // // if too many newlines, resolve up to last newline + // if (newAutocompletion._newlineCount > 10) { + // const lastNewlinePos = fullText.lastIndexOf('\n') + // newAutocompletion.insertText = fullText.substring(0, lastNewlinePos) + // resolve(newAutocompletion.insertText) + // return + // } + + // // if (!getAutocompletionMatchup({ prefix: this._lastPrefix, autocompletion: newAutocompletion })) { + // // reject('LLM response did not match user\'s text.') + // // } + // }, onFinalMessage: ({ fullText }) => { // console.log('____res: ', JSON.stringify(newAutocompletion.insertText)) diff --git a/src/vs/workbench/contrib/void/browser/chatThreadService.ts b/src/vs/workbench/contrib/void/browser/chatThreadService.ts index cc875a79afc..d92fb772d52 100644 --- a/src/vs/workbench/contrib/void/browser/chatThreadService.ts +++ b/src/vs/workbench/contrib/void/browser/chatThreadService.ts @@ -12,12 +12,12 @@ import { URI } from '../../../../base/common/uri.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { IRange } from '../../../../editor/common/core/range.js'; import { ILLMMessageService } from '../common/llmMessageService.js'; -import { IModelService } from '../../../../editor/common/services/model.js'; import { chat_userMessageContent, chat_systemMessage, chat_userMessageContentWithAllFilesToo as chat_userMessageContentWithAllFiles, chat_selectionsString } from './prompt/prompts.js'; -import { IFileService } from '../../../../platform/files/common/files.js'; import { InternalToolInfo, IToolsService, ToolCallReturnType, ToolFns, ToolName, voidTools } from '../common/toolsService.js'; import { toLLMChatMessage } from '../common/llmMessageTypes.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IVoidFileService } from '../common/voidFileService.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; const findLastIndex = (arr: T[], condition: (t: T) => boolean): number => { @@ -48,13 +48,13 @@ export type FileSelection = { export type StagingSelectionItem = CodeSelection | FileSelection -type ToolMessage = { +export type ToolMessage = { role: 'tool'; name: T; // internal use params: string; // internal use id: string; // apis require this tool use id content: string; // result - result: ToolCallReturnType; // text message of result + result: ToolCallReturnType[T]; // text message of result } @@ -73,8 +73,7 @@ export type ChatMessage = stagingSelections: StagingSelectionItem[]; isBeingEdited: boolean; } - } - | { + } | { role: 'assistant'; content: string | null; // content received from LLM - allowed to be '', will be replaced with (empty) displayContent: string | null; // content displayed to user (this is the same as content for now) - allowed to be '', will be ignored @@ -86,7 +85,7 @@ type UserMessageState = UserMessageType['state'] export const defaultMessageState: UserMessageState = { stagingSelections: [], - isBeingEdited: false + isBeingEdited: false, } // a 'thread' means a chat message history @@ -125,7 +124,7 @@ export type ThreadStreamState = { const newThreadObject = () => { const now = new Date().toISOString() return { - id: new Date().getTime().toString(), + id: generateUuid(), createdAt: now, lastModified: now, messages: [], @@ -158,16 +157,25 @@ export interface IChatThreadService { openNewThread(): void; switchToThread(threadId: string): void; + // you can edit multiple messages + // the one you're currently editing is "focused", and we add items to that one when you press cmd+L. getFocusedMessageIdx(): number | undefined; isFocusingMessage(): boolean; setFocusedMessageIdx(messageIdx: number | undefined): void; - // _useFocusedStagingState(messageIdx?: number | undefined): readonly [StagingInfo, (stagingInfo: StagingInfo) => void]; - _useCurrentThreadState(): readonly [ThreadType['state'], (newState: Partial) => void]; - _useCurrentMessageState(messageIdx: number): readonly [UserMessageState, (newState: Partial) => void]; + // exposed getters/setters + getCurrentMessageState: (messageIdx: number) => UserMessageState + setCurrentMessageState: (messageIdx: number, newState: Partial) => void + getCurrentThreadStagingSelections: () => StagingSelectionItem[] + setCurrentThreadStagingSelections: (stagingSelections: StagingSelectionItem[]) => void + + // call to edit a message editUserMessageAndStreamResponse({ userMessage, chatMode, messageIdx }: { userMessage: string, chatMode: ChatMode, messageIdx: number }): Promise; + + // call to add a message addUserMessageAndStreamResponse({ userMessage, chatMode }: { userMessage: string, chatMode: ChatMode }): Promise; + cancelStreaming(threadId: string): void; dismissStreamError(threadId: string): void; @@ -189,8 +197,7 @@ class ChatThreadService extends Disposable implements IChatThreadService { constructor( @IStorageService private readonly _storageService: IStorageService, - @IModelService private readonly _modelService: IModelService, - @IFileService private readonly _fileService: IFileService, + @IVoidFileService private readonly _voidFileService: IVoidFileService, @ILLMMessageService private readonly _llmMessageService: ILLMMessageService, @IToolsService private readonly _toolsService: IToolsService, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @@ -358,7 +365,7 @@ class ChatThreadService extends Disposable implements IChatThreadService { // add user's message to chat history const instructions = userMessage const userMessageContent = await chat_userMessageContent(instructions, currSelns) - const selectionsStr = await chat_selectionsString(prevSelns, currSelns, this._modelService, this._fileService) + const selectionsStr = await chat_selectionsString(prevSelns, currSelns, this._voidFileService) const userMessageFullContent = chat_userMessageContentWithAllFiles(userMessageContent, selectionsStr) const userHistoryElt: ChatMessage = { role: 'user', content: userMessageContent, displayContent: instructions, selections: currSelns, state: defaultMessageState } @@ -423,10 +430,10 @@ class ChatThreadService extends Disposable implements IChatThreadService { // 1. let toolResult: Awaited> - let toolResultVal: ToolCallReturnType + let toolResultVal: ToolCallReturnType[ToolName] try { toolResult = await this._toolsService.toolFns[toolName](tool.params) - toolResultVal = toolResult[0] + toolResultVal = toolResult } catch (error) { this._setStreamState(threadId, { error }) shouldSendAnotherMessage = false @@ -619,33 +626,27 @@ class ChatThreadService extends Disposable implements IChatThreadService { } - // gets `staging` and `setStaging` of the currently focused element, given the index of the currently selected message (or undefined if no message is selected) - - _useCurrentMessageState(messageIdx: number) { - - const thread = this.getCurrentThread() - const messages = thread.messages - const currMessage = messages[messageIdx] - - if (currMessage.role !== 'user') { - return [defaultMessageState, (s: any) => { }] as const - } - - const state = currMessage.state - const setState = (newState: Partial) => this._setCurrentMessageState(newState, messageIdx) - - return [state, setState] as const - + getCurrentThreadStagingSelections = () => { + return this.getCurrentThread().state.stagingSelections } - _useCurrentThreadState() { - const thread = this.getCurrentThread() + setCurrentThreadStagingSelections = (stagingSelections: StagingSelectionItem[]) => { + this._setCurrentThreadState({ stagingSelections }) + } - const state = thread.state - const setState = this._setCurrentThreadState.bind(this) + // gets `staging` and `setStaging` of the currently focused element, given the index of the currently selected message (or undefined if no message is selected) - return [state, setState] as const + getCurrentMessageState(messageIdx: number): UserMessageState { + const currMessage = this.getCurrentThread()?.messages?.[messageIdx] + if (!currMessage || currMessage.role !== 'user') return defaultMessageState + return currMessage.state } + setCurrentMessageState(messageIdx: number, newState: Partial) { + const currMessage = this.getCurrentThread()?.messages?.[messageIdx] + if (!currMessage || currMessage.role !== 'user') return + this._setCurrentMessageState(newState, messageIdx) + } + } diff --git a/src/vs/workbench/contrib/void/browser/editCodeService.ts b/src/vs/workbench/contrib/void/browser/editCodeService.ts index e3a5d998291..0f92d4ef3b5 100644 --- a/src/vs/workbench/contrib/void/browser/editCodeService.ts +++ b/src/vs/workbench/contrib/void/browser/editCodeService.ts @@ -35,14 +35,13 @@ import { filenameToVscodeLanguage } from './helpers/detectLanguage.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { isMacintosh } from '../../../../base/common/platform.js'; import { EditorOption } from '../../../../editor/common/config/editorOptions.js'; -import { Emitter } from '../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; import { VOID_OPEN_SETTINGS_ACTION_ID } from './voidSettingsPane.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { ILLMMessageService } from '../common/llmMessageService.js'; -import { LLMChatMessage, errorDetails } from '../common/llmMessageTypes.js'; +import { LLMChatMessage, OnError, errorDetails } from '../common/llmMessageTypes.js'; import { IMetricsService } from '../common/metricsService.js'; -import { VSReadFile } from './helpers/readFile.js'; -import { IFileService } from '../../../../platform/files/common/files.js'; +import { IVoidFileService } from '../common/voidFileService.js'; const configOfBG = (color: Color) => { return { dark: color, light: color, hcDark: color, hcLight: color, } @@ -66,6 +65,7 @@ registerColor('void.sweepIdxBG', configOfBG(sweepIdxBG), '', true); + const getLeadingWhitespacePx = (editor: ICodeEditor, startLine: number): number => { const model = editor.getModel(); @@ -103,6 +103,27 @@ const getLeadingWhitespacePx = (editor: ICodeEditor, startLine: number): number +// finds block.orig in fileContents and return its range in file +// startingAtLine is 1-indexed and inclusive +const findTextInCode = (text: string, fileContents: string, startingAtLine?: number) => { + const idx = fileContents.indexOf(text, + startingAtLine !== undefined ? + fileContents.split('\n').slice(0, startingAtLine).join('\n').length // num characters in all lines before startingAtLine + : 0 + ) + if (idx === -1) return 'Not found' as const + const lastIdx = fileContents.lastIndexOf(text) + if (lastIdx !== idx) return 'Not unique' as const + const startLine = fileContents.substring(0, idx).split('\n').length + const numLines = text.split('\n').length + const endLine = startLine + numLines - 1 + return [startLine, endLine] as const +} + + +export type URIStreamState = 'idle' | 'acceptRejectAll' | 'streaming' + + export type StartApplyingOpts = { from: 'QuickEdit'; type: 'rewrite'; @@ -114,6 +135,7 @@ export type StartApplyingOpts = { } + export type AddCtrlKOpts = { startLine: number, endLine: number, @@ -121,7 +143,7 @@ export type AddCtrlKOpts = { } // // TODO diffArea should be removed if we just discovered it has no more diffs in it -// for (const diffareaid of this.diffAreasOfURI[uri.fsPath]) { +// for (const diffareaid of this.diffAreasOfURI[uri.fsPath] || []) { // const diffArea = this.diffAreaOfId[diffareaid] // if (Object.keys(diffArea._diffOfId).length === 0 && !diffArea._sweepState.isStreaming) { // const { onFinishEdit } = this._addToHistory(uri) @@ -148,7 +170,6 @@ type CommonZoneProps = { _URI: URI; // typically we get the URI from model - _removeStylesFns: Set; // these don't remove diffs or this diffArea, only their styles } type CtrlKZone = { @@ -164,6 +185,7 @@ type CtrlKZone = { } _linkedStreamingDiffZone: number | null; // diffareaid of the diffZone currently streaming here + _removeStylesFns: Set // these don't remove diffs or this diffArea, only their styles } & CommonZoneProps @@ -183,12 +205,22 @@ type DiffZone = { }; editorId?: undefined; linkedStreamingDiffZone?: undefined; + _removeStylesFns: Set // these don't remove diffs or this diffArea, only their styles } & CommonZoneProps +type TrackingZone = { + type: 'TrackingZone'; + metadata: T; + originalCode?: undefined; + editorId?: undefined; + _removeStylesFns?: undefined; +} & CommonZoneProps + + // called DiffArea for historical purposes, we can rename to something like TextRegion if we want -type DiffArea = CtrlKZone | DiffZone +type DiffArea = CtrlKZone | DiffZone | TrackingZone const diffAreaSnapshotKeys = [ 'type', @@ -217,10 +249,22 @@ type StreamLocationMutable = { line: number, col: number, addedSplitYet: boolean export interface IEditCodeService { readonly _serviceBrand: undefined; - startApplying(opts: StartApplyingOpts): number | undefined; - interruptStreaming(diffareaid: number): void; + startApplying(opts: StartApplyingOpts): URI | null; + addCtrlKZone(opts: AddCtrlKOpts): number | undefined; removeCtrlKZone(opts: { diffareaid: number }): void; + removeDiffAreas(opts: { uri: URI, removeCtrlKs: boolean, behavior: 'reject' | 'accept' }): void; + + // CtrlKZone streaming state + isCtrlKZoneStreaming(opts: { diffareaid: number }): boolean; + interruptCtrlKStreaming(opts: { diffareaid: number }): void; + onDidChangeCtrlKZoneStreaming: Event<{ uri: URI; diffareaid: number }>; + + // // DiffZone codeBoxId streaming state + getURIStreamState(opts: { uri: URI | null }): URIStreamState; + interruptURIStreaming(opts: { uri: URI }): void; + onDidChangeURIStreamState: Event<{ uri: URI; state: URIStreamState }>; + // testDiffs(): void; } @@ -239,9 +283,17 @@ class EditCodeService extends Disposable implements IEditCodeService { // only applies to diffZones // streamingDiffZones: Set = new Set() - private readonly _onDidChangeStreaming = new Emitter<{ uri: URI; diffareaid: number }>(); + private readonly _onDidChangeDiffZoneStreaming = new Emitter<{ uri: URI; diffareaid: number }>(); private readonly _onDidAddOrDeleteDiffZones = new Emitter<{ uri: URI }>(); + private readonly _onDidChangeCtrlKZoneStreaming = new Emitter<{ uri: URI; diffareaid: number }>(); + onDidChangeCtrlKZoneStreaming = this._onDidChangeCtrlKZoneStreaming.event + + private readonly _onDidChangeURIStreamState = new Emitter<{ uri: URI; state: URIStreamState }>(); + onDidChangeURIStreamState = this._onDidChangeURIStreamState.event + + + constructor( // @IHistoryService private readonly _historyService: IHistoryService, // history service is the history of pressing alt left/right @@ -255,7 +307,7 @@ class EditCodeService extends Disposable implements IEditCodeService { @IMetricsService private readonly _metricsService: IMetricsService, @INotificationService private readonly _notificationService: INotificationService, @ICommandService private readonly _commandService: ICommandService, - @IFileService private readonly _fileService: IFileService, + @IVoidFileService private readonly _voidFileService: IVoidFileService, ) { super(); @@ -276,23 +328,32 @@ class EditCodeService extends Disposable implements IEditCodeService { }) ) - // when a stream starts or ends - let removeAcceptRejectAllUI: (() => void) | null = null - const onChangeUriState = () => { - const uri = model.uri - const diffZones = [...this.diffAreasOfURI[uri.fsPath].values()] - .map(diffareaid => this.diffAreaOfId[diffareaid]) - .filter(diffArea => !!diffArea && diffArea.type === 'DiffZone') - const isStreaming = diffZones.find(diffZone => !!diffZone._streamState.isStreaming) - if (diffZones.length !== 0 && !isStreaming && !removeAcceptRejectAllUI) { - removeAcceptRejectAllUI = this._addAcceptRejectAllUI(uri) ?? null + // when a stream starts or ends, fire the event for onDidChangeURIStreamState + let prevStreamState = this.getURIStreamState({ uri: model.uri }) + const updateAcceptRejectAllUI = () => { + const state = this.getURIStreamState({ uri: model.uri }) + let prevStateActual = prevStreamState + prevStreamState = state + if (state === prevStateActual) return + this._onDidChangeURIStreamState.fire({ uri: model.uri, state }) + } + + + let _removeAcceptRejectAllUI: (() => void) | null = null + this._register(this._onDidChangeURIStreamState.event(({ uri, state }) => { + if (uri.fsPath !== model.uri.fsPath) return + if (state === 'acceptRejectAll') { + if (!_removeAcceptRejectAllUI) + _removeAcceptRejectAllUI = this._addAcceptRejectAllUI(model.uri) ?? null } else { - removeAcceptRejectAllUI?.() - removeAcceptRejectAllUI = null + _removeAcceptRejectAllUI?.() + _removeAcceptRejectAllUI = null } - } - this._register(this._onDidAddOrDeleteDiffZones.event(({ uri: uri_ }) => { if (uri_.fsPath === model.uri.fsPath) onChangeUriState() })) - this._register(this._onDidChangeStreaming.event(({ uri: uri_ }) => { if (uri_.fsPath === model.uri.fsPath) onChangeUriState() })) + })) + this._register(this._onDidChangeDiffZoneStreaming.event(({ uri: uri_ }) => { if (uri_.fsPath === model.uri.fsPath) updateAcceptRejectAllUI() })) + this._register(this._onDidAddOrDeleteDiffZones.event(({ uri: uri_ }) => { if (uri_.fsPath === model.uri.fsPath) updateAcceptRejectAllUI() })) + + } // initialize all existing models + initialize when a new model mounts for (let model of this._modelService.getModels()) { initializeModel(model) } @@ -329,6 +390,29 @@ class EditCodeService extends Disposable implements IEditCodeService { } + + + private _notifyError = (e: Parameters[0]) => { + const details = errorDetails(e.fullError) + this._notificationService.notify({ + severity: Severity.Warning, + message: `Void Error: ${e.message}`, + actions: { + secondary: [{ + id: 'void.onerror.opensettings', + enabled: true, + label: `Open Void's settings`, + tooltip: '', + class: undefined, + run: () => { this._commandService.executeCommand(VOID_OPEN_SETTINGS_ACTION_ID) } + }] + }, + source: details ? `(Hold ${isMacintosh ? 'Option' : 'Alt'} to hover) - ${details}\n\nIf this persists, feel free to [report](https://github.com/voideditor/void/issues/new) it.` : undefined + }) + } + + + // highlight the region private _addLineDecoration = (model: ITextModel | null, startLine: number, endLine: number, className: string, options?: Partial) => { if (model === null) return @@ -350,7 +434,7 @@ class EditCodeService extends Disposable implements IEditCodeService { private _addDiffAreaStylesToURI = (uri: URI) => { const model = this._getModel(uri) - for (const diffareaid of this.diffAreasOfURI[uri.fsPath]) { + for (const diffareaid of this.diffAreasOfURI[uri.fsPath] || []) { const diffArea = this.diffAreaOfId[diffareaid] if (diffArea.type === 'DiffZone') { @@ -379,7 +463,7 @@ class EditCodeService extends Disposable implements IEditCodeService { private _computeDiffsAndAddStylesToURI = (uri: URI) => { const fullFileText = this._readURI(uri) ?? '' - for (const diffareaid of this.diffAreasOfURI[uri.fsPath]) { + for (const diffareaid of this.diffAreasOfURI[uri.fsPath] || []) { const diffArea = this.diffAreaOfId[diffareaid] if (diffArea.type !== 'DiffZone') continue @@ -403,7 +487,7 @@ class EditCodeService extends Disposable implements IEditCodeService { // find all diffzones that aren't streaming const diffZones: DiffZone[] = [] - for (let diffareaid of this.diffAreasOfURI[uri.fsPath]) { + for (let diffareaid of this.diffAreasOfURI[uri.fsPath] || []) { const diffArea = this.diffAreaOfId[diffareaid] if (diffArea.type !== 'DiffZone') continue if (diffArea._streamState.isStreaming) continue @@ -472,7 +556,6 @@ class EditCodeService extends Disposable implements IEditCodeService { mountCtrlK(domNode, accessor, { diffareaid: ctrlKZone.diffareaid, - initStreamingDiffZoneId: ctrlKZone._linkedStreamingDiffZone, textAreaRef: (r) => { textAreaRef.current = r @@ -522,7 +605,7 @@ class EditCodeService extends Disposable implements IEditCodeService { private _refreshCtrlKInputs = async (uri: URI) => { - for (const diffareaid of this.diffAreasOfURI[uri.fsPath]) { + for (const diffareaid of this.diffAreasOfURI[uri.fsPath] || []) { const diffArea = this.diffAreaOfId[diffareaid] if (diffArea.type !== 'CtrlKZone') continue if (!diffArea._mountInfo) { @@ -784,7 +867,7 @@ class EditCodeService extends Disposable implements IEditCodeService { this.diffAreaOfId[diffareaid] = { ...snapshottedDiffArea as DiffAreaSnapshot, _URI: uri, - _removeStylesFns: new Set(), + _removeStylesFns: new Set(), _mountInfo: null, _linkedStreamingDiffZone: null, // when restoring, we will never be streaming } @@ -842,14 +925,14 @@ class EditCodeService extends Disposable implements IEditCodeService { if (diffArea.type === 'DiffZone') this._deleteDiffs(diffArea) - diffArea._removeStylesFns.forEach(removeStyles => removeStyles()) - diffArea._removeStylesFns.clear() + diffArea._removeStylesFns?.forEach(removeStyles => removeStyles()) + diffArea._removeStylesFns?.clear() } // clears all Diffs (and their styles) and all styles of DiffAreas, etc private _clearAllEffects(uri: URI) { - for (let diffareaid of this.diffAreasOfURI[uri.fsPath]) { + for (let diffareaid of this.diffAreasOfURI[uri.fsPath] || []) { const diffArea = this.diffAreaOfId[diffareaid] this._clearAllDiffAreaEffects(diffArea) } @@ -864,6 +947,11 @@ class EditCodeService extends Disposable implements IEditCodeService { this._onDidAddOrDeleteDiffZones.fire({ uri: diffZone._URI }) } + private _deleteTrackingZone(trackingZone: TrackingZone) { + delete this.diffAreaOfId[trackingZone.diffareaid] + this.diffAreasOfURI[trackingZone._URI.fsPath].delete(trackingZone.diffareaid.toString()) + } + private _deleteCtrlKZone(ctrlKZone: CtrlKZone) { this._clearAllEffects(ctrlKZone._URI) ctrlKZone._mountInfo?.dispose() @@ -1001,44 +1089,37 @@ class EditCodeService extends Disposable implements IEditCodeService { // @throttle(100) - private _writeStreamedDiffZoneLLMText(diffZone: DiffZone, llmText: string, deltaText: string, latestMutable: StreamLocationMutable) { + private _writeStreamedDiffZoneLLMText(uri: URI, originalCode: string, llmTextSoFar: string, deltaText: string, latestMutable: StreamLocationMutable) { + + let numNewLines = 0 // ----------- 1. Write the new code to the document ----------- // figure out where to highlight based on where the AI is in the stream right now, use the last diff to figure that out - const uri = diffZone._URI - const computedDiffs = findDiffs(diffZone.originalCode, llmText) - - // should always be in streaming state here - if (!diffZone._streamState.isStreaming) { - console.error('DiffZone was not in streaming state on _writeDiffZoneLLMText') - return - } + const computedDiffs = findDiffs(originalCode, llmTextSoFar) // if streaming, use diffs to figure out where to write new code // these are two different coordinate systems - new and old line number - let newCodeEndLine: number // get file[diffArea.startLine...newFileEndLine] with line=newFileEndLine highlighted - let originalCodeStartLine: number // get original[oldStartingPoint...] (line in the original code, so starts at 1) + let endLineInLlmTextSoFar: number // get file[diffArea.startLine...newFileEndLine] with line=newFileEndLine highlighted + let startLineInOriginalCode: number // get original[oldStartingPoint...] (line in the original code, so starts at 1) const lastDiff = computedDiffs.pop() if (!lastDiff) { // console.log('!lastDiff') // if the writing is identical so far, display no changes - originalCodeStartLine = 1 - newCodeEndLine = 1 + startLineInOriginalCode = 1 + endLineInLlmTextSoFar = 1 } else { - originalCodeStartLine = lastDiff.originalStartLine + startLineInOriginalCode = lastDiff.originalStartLine if (lastDiff.type === 'insertion' || lastDiff.type === 'edit') - newCodeEndLine = lastDiff.endLine + endLineInLlmTextSoFar = lastDiff.endLine else if (lastDiff.type === 'deletion') - newCodeEndLine = lastDiff.startLine + endLineInLlmTextSoFar = lastDiff.startLine else throw new Error(`Void: diff.type not recognized on: ${lastDiff}`) } - - // at the start, add a newline between the stream and originalCode to make reasoning easier if (!latestMutable.addedSplitYet) { this._writeText(uri, '\n', @@ -1046,6 +1127,7 @@ class EditCodeService extends Disposable implements IEditCodeService { { shouldRealignDiffAreas: true } ) latestMutable.addedSplitYet = true + numNewLines += 1 } // insert deltaText at latest line and col @@ -1053,32 +1135,33 @@ class EditCodeService extends Disposable implements IEditCodeService { { startLineNumber: latestMutable.line, startColumn: latestMutable.col, endLineNumber: latestMutable.line, endColumn: latestMutable.col }, { shouldRealignDiffAreas: true } ) - latestMutable.line += deltaText.split('\n').length - 1 + const deltaNumNewLines = deltaText.split('\n').length - 1 + latestMutable.line += deltaNumNewLines const lastNewlineIdx = deltaText.lastIndexOf('\n') latestMutable.col = lastNewlineIdx === -1 ? latestMutable.col + deltaText.length : deltaText.length - lastNewlineIdx + numNewLines += deltaNumNewLines // delete or insert to get original up to speed - if (latestMutable.originalCodeStartLine < originalCodeStartLine) { + if (latestMutable.originalCodeStartLine < startLineInOriginalCode) { // moved up, delete - const numLinesDeleted = originalCodeStartLine - latestMutable.originalCodeStartLine + const numLinesDeleted = startLineInOriginalCode - latestMutable.originalCodeStartLine this._writeText(uri, '', { startLineNumber: latestMutable.line, startColumn: latestMutable.col, endLineNumber: latestMutable.line + numLinesDeleted, endColumn: Number.MAX_SAFE_INTEGER, }, { shouldRealignDiffAreas: true } ) + numNewLines -= numLinesDeleted } - else if (latestMutable.originalCodeStartLine > originalCodeStartLine) { - this._writeText(uri, '\n' + diffZone.originalCode.split('\n').slice((originalCodeStartLine - 1), (latestMutable.originalCodeStartLine - 1) - 1 + 1).join('\n'), + else if (latestMutable.originalCodeStartLine > startLineInOriginalCode) { + const newText = '\n' + originalCode.split('\n').slice((startLineInOriginalCode - 1), (latestMutable.originalCodeStartLine - 1) - 1 + 1).join('\n') + this._writeText(uri, newText, { startLineNumber: latestMutable.line, startColumn: latestMutable.col, endLineNumber: latestMutable.line, endColumn: latestMutable.col }, { shouldRealignDiffAreas: true } ) + numNewLines += newText.split('\n').length - 1 } - latestMutable.originalCodeStartLine = originalCodeStartLine - - // add diffZone.startLine to convert to right coordinate system (line in file, not in diffarea) - diffZone._streamState.line = (diffZone.startLine - 1) + newCodeEndLine - - return computedDiffs + latestMutable.originalCodeStartLine = startLineInOriginalCode + return { endLineInLlmTextSoFar, numNewLines } // numNewLines here might not be correct.... } @@ -1144,19 +1227,15 @@ class EditCodeService extends Disposable implements IEditCodeService { public startApplying(opts: StartApplyingOpts) { - if (opts.type === 'rewrite') { - const addedDiffZone = this._initializeRewriteStream(opts) - return addedDiffZone?.diffareaid + const addedDiffArea = this._initializeWriteoverStream(opts) + return addedDiffArea?._URI ?? null } - else if (opts.type === 'searchReplace') { - this._initializeSearchAndReplaceStream(opts) - return undefined + const addedDiffArea = this._initializeSearchAndReplaceStream(opts) + return addedDiffArea?._URI ?? null } - - else return undefined - + return null } @@ -1164,7 +1243,7 @@ class EditCodeService extends Disposable implements IEditCodeService { private _findOverlappingDiffArea({ startLine, endLine, uri, filter }: { startLine: number, endLine: number, uri: URI, filter?: (diffArea: DiffArea) => boolean }): DiffArea | null { // check if there's overlap with any other diffAreas and return early if there is - for (const diffareaid of this.diffAreasOfURI[uri.fsPath]) { + for (const diffareaid of this.diffAreasOfURI[uri.fsPath] || []) { const diffArea = this.diffAreaOfId[diffareaid] if (!diffArea) continue if (!filter?.(diffArea)) continue @@ -1177,240 +1256,11 @@ class EditCodeService extends Disposable implements IEditCodeService { } - private async _initializeSearchAndReplaceStream({ applyStr }: { applyStr: string }) { - - const uri_ = this._getActiveEditorURI() - if (!uri_) return - const uri = uri_ - - // generate search/replace block text - const origFileContents = await VSReadFile(uri, this._modelService, this._fileService) - if (origFileContents === null) return - - - // // reject all diffZones on this URI, adding to history (there can't possibly be overlap after this) - // this.removeDiffAreas({ uri, behavior: 'reject', removeCtrlKs: true }) - - const userMessageContent = searchReplace_userMessage({ originalCode: origFileContents, applyStr: applyStr }) - const messages: LLMChatMessage[] = [ - { role: 'system', content: searchReplace_systemMessage }, - { role: 'user', content: userMessageContent }, - ] - let streamRequestIdRef: { current: string | null } = { current: null } - - const diffareaidOfBlockNum: number[] = [] - const diffAreaOriginalLines: [number, number][] = [] - - // TODO replace all these with whatever block we're on initially if already started - let latestStreamLocationMutable: StreamLocationMutable | null = null - let currStreamingBlockNum = 0 - let oldBlocks: ExtractedSearchReplaceBlock[] = [] - - // find block.orig in fileContents and return its range in file - const findTextInCode = (text: string, fileContents: string) => { - const idx = fileContents.indexOf(text) - if (idx === -1) return 'Not found' as const - const lastIdx = fileContents.lastIndexOf(text) - if (lastIdx !== idx) return 'Not unique' as const - const startLine = fileContents.substring(0, idx).split('\n').length - const numLines = text.split('\n').length - const endLine = startLine + numLines - 1 - return [startLine, endLine] - } - - let { onFinishEdit } = this._addToHistory(uri) - - const revertAndContinueHistory = () => { - this._undoHistory(uri) - const { onFinishEdit: onFinishEdit_ } = this._addToHistory(uri) - onFinishEdit = onFinishEdit_ - } - - const onDone = (errorMessage: false | string) => { - for (const blockNum in diffareaidOfBlockNum) { - const diffareaid = diffareaidOfBlockNum[blockNum] - const diffZone = this.diffAreaOfId[diffareaid] - if (diffZone?.type !== 'DiffZone') continue - diffZone._streamState = { isStreaming: false, } - this._onDidChangeStreaming.fire({ uri, diffareaid: diffZone.diffareaid }) - } - this._refreshStylesAndDiffsInURI(uri) - if (errorMessage) { - this._notificationService.info(`Void had an error when running Apply: ${errorMessage}.\nIf this persists, feel free to [report](https://github.com/voideditor/void/issues/new) this error.`) - this._metricsService.capture('Error - Apply', { errorMessage }) - this._undoHistory(uri) - } - onFinishEdit() - } - - - const onNewBlockStart = (blockNum: number, block: ExtractedSearchReplaceBlock): { errorStartingBlock?: undefined } | { errorStartingBlock: string } => { - console.log('STARTING BLOCK', JSON.stringify(block, null, 2)) - - const foundInCode = findTextInCode(block.orig, origFileContents) - if (typeof foundInCode === 'string') { - console.log('Apply error:', foundInCode, '; trying again.') - return { errorStartingBlock: foundInCode } - } - const [originalStart, originalEnd] = foundInCode - - let lineOffset = 0 - // compute line offset given multiple changes - for (let i = 0; i < blockNum; i += 1) { - const [diffAreaOriginalStart, diffAreaOriginalEnd] = diffAreaOriginalLines[i] - console.log('ROIGGINAL!!!', diffAreaOriginalStart, diffAreaOriginalEnd) - if (diffAreaOriginalStart > originalEnd) continue - - const diffareaid = diffareaidOfBlockNum[i] - const diffArea = this.diffAreaOfId[diffareaid] - - - const numNewLines = diffArea.endLine - diffArea.startLine - const numOldLines = diffAreaOriginalEnd - diffAreaOriginalStart - console.log('NUM NEW', numNewLines, numOldLines) - - lineOffset += numNewLines - numOldLines - } - - const startLine = originalStart + lineOffset - const endLine = originalEnd + lineOffset - console.log('adding to', startLine, endLine) - - const adding: Omit = { - type: 'DiffZone', - originalCode: block.orig, - startLine, - endLine, - _URI: uri, - _streamState: { - isStreaming: true, - streamRequestIdRef, - line: startLine, - }, - _diffOfId: {}, // added later - _removeStylesFns: new Set(), - } - const diffZone = this._addDiffArea(adding) - this._onDidChangeStreaming.fire({ uri, diffareaid: diffZone.diffareaid }) - this._onDidAddOrDeleteDiffZones.fire({ uri }) - - diffareaidOfBlockNum.push(diffZone.diffareaid) - diffAreaOriginalLines.push([originalStart, originalEnd]) - - latestStreamLocationMutable = { line: diffZone.startLine, addedSplitYet: false, col: 1, originalCodeStartLine: 1 } - return { errorStartingBlock: undefined } - } - - - - - - - let shouldSendAnotherMessage = true - let nMessagesSent = 0 - // this generates >>>>>>> ORIGINAL <<<<<<< REPLACE blocks and and simultaneously applies it - while (shouldSendAnotherMessage) { - shouldSendAnotherMessage = false - nMessagesSent += 1 - - streamRequestIdRef.current = this._llmMessageService.sendLLMMessage({ - messagesType: 'chatMessages', - useProviderFor: 'Apply', - logging: { loggingName: `generateSearchAndReplace` }, - messages, - onText: ({ fullText }) => { - const blocks = extractSearchReplaceBlocks(fullText) - - for (let blockNum = currStreamingBlockNum; blockNum < blocks.length; blockNum += 1) { - const block = blocks[blockNum] - - if (block.state === 'done') - currStreamingBlockNum = blockNum - - if (block.state === 'writingOriginal') // must be done writing original - continue - - // if this is the first time we're seeing this block, add it as a diffarea - if (!(blockNum in diffareaidOfBlockNum)) { - console.log('FULLTEXT!!!!!\n', fullText) - const { errorStartingBlock } = onNewBlockStart(blockNum, block) - - if (errorStartingBlock) { - console.log('ERROR STARTING BLOCK SPOT!!!!!', errorStartingBlock) - - const errMsgForLLM = errorStartingBlock === 'Not found' ? - 'I interrupted you because the latest ORIGINAL code could not be found in the file. Please output all SEARCH/REPLACE blocks again, making sure the code in ORIGINAL is identical to a code snippet in the file.' - : errorStartingBlock === 'Not unique' ? - 'I interrupted you because the latest ORIGINAL code shows up multiple times in the file. Please output all SEARCH/REPLACE blocks again, making sure the code in each ORIGINAL section is unique in the file.' - : '' - - messages.push( - { role: 'assistant', content: fullText }, // latest output - { role: 'user', content: errMsgForLLM } // user explanation of what's wrong - ) - if (streamRequestIdRef.current) this._llmMessageService.abort(streamRequestIdRef.current) - - shouldSendAnotherMessage = true - revertAndContinueHistory() - return - } - - } - const deltaFinalText = block.final.substring((oldBlocks[blockNum]?.final ?? '').length, Infinity) - oldBlocks = blocks - - // write new text to diffarea - const diffareaid = diffareaidOfBlockNum[blockNum] - const diffZone = this.diffAreaOfId[diffareaid] - if (diffZone?.type !== 'DiffZone') continue - - - if (!latestStreamLocationMutable) continue - this._writeStreamedDiffZoneLLMText(diffZone, block.final, deltaFinalText, latestStreamLocationMutable) - } // end for - - this._refreshStylesAndDiffsInURI(uri) - }, - onFinalMessage: async ({ fullText }) => { - console.log('final message!!', fullText) - - // 1. wait 500ms and fix lint errors - call lint error workflow - // (update react state to say "Fixing errors") - const blocks = extractSearchReplaceBlocks(fullText) - - if (blocks.length === 0) { - this._notificationService.info(`Void: When running Apply, your model didn't output any changes we recognized. You might need to use a smarter model for Apply.`) - } - - for (let blockNum = 0; blockNum < blocks.length; blockNum += 1) { - const block = blocks[blockNum] - const diffareaid = diffareaidOfBlockNum[blockNum] - const diffZone = this.diffAreaOfId[diffareaid] - if (diffZone?.type !== 'DiffZone') continue - - this._writeText(uri, block.final, - { startLineNumber: diffZone.startLine, startColumn: 1, endLineNumber: diffZone.endLine, endColumn: Number.MAX_SAFE_INTEGER }, // 1-indexed - { shouldRealignDiffAreas: true } - ) - } - onDone(false) - }, - onError: (e) => { - console.log('ERROR in SearchReplace:', e.message) - onDone(e.message) - }, - - }) - } - - - } - - private _initializeRewriteStream(opts: StartApplyingOpts): DiffZone | undefined { + private _initializeWriteoverStream(opts: StartApplyingOpts): DiffZone | undefined { const { from } = opts @@ -1477,7 +1327,7 @@ class EditCodeService extends Disposable implements IEditCodeService { _removeStylesFns: new Set(), } const diffZone = this._addDiffArea(adding) - this._onDidChangeStreaming.fire({ uri, diffareaid: diffZone.diffareaid }) + this._onDidChangeDiffZoneStreaming.fire({ uri, diffareaid: diffZone.diffareaid }) this._onDidAddOrDeleteDiffZones.fire({ uri }) if (from === 'QuickEdit') { @@ -1486,6 +1336,7 @@ class EditCodeService extends Disposable implements IEditCodeService { if (ctrlKZone.type !== 'CtrlKZone') return ctrlKZone._linkedStreamingDiffZone = diffZone.diffareaid + this._onDidChangeCtrlKZoneStreaming.fire({ uri, diffareaid: ctrlKZone.diffareaid }) } // now handle messages @@ -1517,23 +1368,19 @@ class EditCodeService extends Disposable implements IEditCodeService { else { throw new Error(`featureName ${from} is invalid`) } - const onDone = (hadError: boolean) => { + const onDone = () => { diffZone._streamState = { isStreaming: false, } - this._onDidChangeStreaming.fire({ uri, diffareaid: diffZone.diffareaid }) + this._onDidChangeDiffZoneStreaming.fire({ uri, diffareaid: diffZone.diffareaid }) if (from === 'QuickEdit') { const ctrlKZone = this.diffAreaOfId[opts.diffareaid] as CtrlKZone ctrlKZone._linkedStreamingDiffZone = null + this._onDidChangeCtrlKZoneStreaming.fire({ uri, diffareaid: ctrlKZone.diffareaid }) this._deleteCtrlKZone(ctrlKZone) } this._refreshStylesAndDiffsInURI(uri) onFinishEdit() - - // if had error, revert! - if (hadError) { - this._undoHistory(diffZone._URI) - } } // refresh now in case onText takes a while to get 1st message @@ -1567,7 +1414,9 @@ class EditCodeService extends Disposable implements IEditCodeService { fullText += prevIgnoredSuffix + newText // full text, including ```, etc const [croppedText, deltaCroppedText, croppedSuffix] = extractText(fullText, newText.length) - this._writeStreamedDiffZoneLLMText(diffZone, croppedText, deltaCroppedText, latestStreamInfoMutable) + const { endLineInLlmTextSoFar } = this._writeStreamedDiffZoneLLMText(uri, originalCode, croppedText, deltaCroppedText, latestStreamInfoMutable) + diffZone._streamState.line = (diffZone.startLine - 1) + endLineInLlmTextSoFar // change coordinate systems from originalCode to full file + this._refreshStylesAndDiffsInURI(uri) prevIgnoredSuffix = croppedSuffix @@ -1580,26 +1429,12 @@ class EditCodeService extends Disposable implements IEditCodeService { { startLineNumber: diffZone.startLine, startColumn: 1, endLineNumber: diffZone.endLine, endColumn: Number.MAX_SAFE_INTEGER }, // 1-indexed { shouldRealignDiffAreas: true } ) - onDone(false) + onDone() }, onError: (e) => { - const details = errorDetails(e.fullError) - this._notificationService.notify({ - severity: Severity.Warning, - message: `Void Error: ${e.message}`, - actions: { - secondary: [{ - id: 'void.onerror.opensettings', - enabled: true, - label: 'Open Void settings', - tooltip: '', - class: undefined, - run: () => { this._commandService.executeCommand(VOID_OPEN_SETTINGS_ACTION_ID) } - }] - }, - source: details ? `(Hold ${isMacintosh ? 'Option' : 'Alt'} to hover) - ${details}` : undefined - }) - onDone(true) + this._notifyError(e) + onDone() + this._undoHistory(uri) }, }) @@ -1611,6 +1446,281 @@ class EditCodeService extends Disposable implements IEditCodeService { + private _initializeSearchAndReplaceStream(opts: StartApplyingOpts & { from: 'ClickApply' }) { + const { applyStr } = opts + + const uri_ = this._getActiveEditorURI() + if (!uri_) return + const uri = uri_ + + // generate search/replace block text + const originalFileCode = this._voidFileService.readModel(uri) + if (originalFileCode === null) return + + const numLines = this._getNumLines(uri) + if (numLines === null) return + + // reject all diffZones on this URI, adding to history (there can't possibly be overlap after this) + this.removeDiffAreas({ uri, behavior: 'reject', removeCtrlKs: true }) + + const startLine = 1 + const endLine = numLines + + const userMessageContent = searchReplace_userMessage({ originalCode: originalFileCode, applyStr: applyStr }) + const messages: LLMChatMessage[] = [ + { role: 'system', content: searchReplace_systemMessage }, + { role: 'user', content: userMessageContent }, + ] + + // can use this as a proxy to set the diffArea's stream state requestId + let streamRequestIdRef: { current: string | null } = { current: null } + + let { onFinishEdit } = this._addToHistory(uri) + + // TODO replace these with whatever block we're on initially if already started + + type SearchReplaceDiffAreaMetadata = { + originalBounds: [number, number], // 1-indexed + originalCode: string, + } + + const addedTrackingZoneOfBlockNum: TrackingZone[] = [] + + const adding: Omit = { + type: 'DiffZone', + originalCode: originalFileCode, + startLine, + endLine, + _URI: uri, + _streamState: { + isStreaming: true, + streamRequestIdRef, + line: startLine, + }, + _diffOfId: {}, // added later + _removeStylesFns: new Set(), + } + const diffZone = this._addDiffArea(adding) + this._onDidChangeDiffZoneStreaming.fire({ uri, diffareaid: diffZone.diffareaid }) + this._onDidAddOrDeleteDiffZones.fire({ uri }) + + + const revertAndContinueHistory = () => { + this._undoHistory(uri) + const { onFinishEdit: onFinishEdit_ } = this._addToHistory(uri) + onFinishEdit = onFinishEdit_ + } + + + const convertOriginalRangeToFinalRange = (originalRange: readonly [number, number]): [number, number] => { + // adjust based on the changes by computing line offset + const [originalStart, originalEnd] = originalRange + let lineOffset = 0 + for (const blockDiffArea of addedTrackingZoneOfBlockNum) { + const { + startLine, endLine, + metadata: { originalBounds: [originalStart2, originalEnd2], }, + } = blockDiffArea + if (originalStart2 >= originalEnd) continue + const numNewLines = endLine - startLine + 1 + const numOldLines = originalEnd2 - originalStart2 + 1 + lineOffset += numNewLines - numOldLines + } + return [originalStart + lineOffset, originalEnd + lineOffset] + } + + + const errMsgOfInvalidStr = (str: string & ReturnType) => { + return str === 'Not found' ? + 'I interrupted you because the latest ORIGINAL code could not be found in the file. Please output all SEARCH/REPLACE blocks again, making sure the code in ORIGINAL is identical to a code snippet in the file.' + : str === 'Not unique' ? + 'I interrupted you because the latest ORIGINAL code shows up multiple times in the file. Please output all SEARCH/REPLACE blocks again, making sure the code in each ORIGINAL section is unique in the file.' + : '' + } + + + const onDone = () => { + diffZone._streamState = { isStreaming: false, } + this._onDidChangeDiffZoneStreaming.fire({ uri, diffareaid: diffZone.diffareaid }) + this._refreshStylesAndDiffsInURI(uri) + + // delete the tracking zones + for (const trackingZone of addedTrackingZoneOfBlockNum) + this._deleteTrackingZone(trackingZone) + + onFinishEdit() + } + + // refresh now in case onText takes a while to get 1st message + this._refreshStylesAndDiffsInURI(uri) + + // stream style related + let latestStreamLocationMutable: StreamLocationMutable | null = null + let shouldUpdateOrigStreamStyle = true + + let oldBlocks: ExtractedSearchReplaceBlock[] = [] + + // this generates >>>>>>> ORIGINAL <<<<<<< REPLACE blocks and and simultaneously applies it + let shouldSendAnotherMessage = true + let nMessagesSent = 0 + let currStreamingBlockNum = 0 + while (shouldSendAnotherMessage) { + shouldSendAnotherMessage = false + nMessagesSent += 1 + + streamRequestIdRef.current = this._llmMessageService.sendLLMMessage({ + messagesType: 'chatMessages', + useProviderFor: 'Apply', + logging: { loggingName: `generateSearchAndReplace` }, + messages, + onText: ({ fullText }) => { + // blocks are [done done done ... {writingFinal|writingOriginal}] + // ^ + // currStreamingBlockNum + + const blocks = extractSearchReplaceBlocks(fullText) + + for (let blockNum = currStreamingBlockNum; blockNum < blocks.length; blockNum += 1) { + const block = blocks[blockNum] + + if (block.state === 'writingOriginal') { + // update stream state to the first line of original if some portion of original has been written + if (shouldUpdateOrigStreamStyle && block.orig.trim().length >= 20) { + const startingAtLine = diffZone._streamState.line ?? 1 // dont go backwards if already have a stream line + const originalRange = findTextInCode(block.orig, originalFileCode, startingAtLine) + if (typeof originalRange !== 'string') { + const [startLine, _] = convertOriginalRangeToFinalRange(originalRange) + diffZone._streamState.line = startLine + shouldUpdateOrigStreamStyle = false + } + } + // must be done writing original to move on to writing streamed content + continue + } + shouldUpdateOrigStreamStyle = true + + + // if this is the first time we're seeing this block, add it as a diffarea so we can start streaming + if (!(blockNum in addedTrackingZoneOfBlockNum)) { + const originalBounds = findTextInCode(block.orig, originalFileCode) + + // if error + if (typeof originalBounds === 'string') { + messages.push( + { role: 'assistant', content: fullText }, // latest output + { role: 'user', content: errMsgOfInvalidStr(originalBounds) } // user explanation of what's wrong + ) + if (streamRequestIdRef.current) this._llmMessageService.abort(streamRequestIdRef.current) + shouldSendAnotherMessage = true + revertAndContinueHistory() + continue + } + + const [startLine, endLine] = convertOriginalRangeToFinalRange(originalBounds) + + // otherwise if no error, add the position as a diffarea + const adding: Omit, 'diffareaid'> = { + type: 'TrackingZone', + startLine: startLine, + endLine: endLine, + _URI: uri, + metadata: { + originalBounds: [...originalBounds], + originalCode: block.orig, + }, + } + const trackingZone = this._addDiffArea(adding) + addedTrackingZoneOfBlockNum.push(trackingZone) + latestStreamLocationMutable = { line: startLine, addedSplitYet: false, col: 1, originalCodeStartLine: 1 } + } // <-- done adding diffarea + + + // should always be in streaming state here + if (!diffZone._streamState.isStreaming) { + console.error('DiffZone was not in streaming state in _initializeSearchAndReplaceStream') + continue + } + if (!latestStreamLocationMutable) continue + + // if a block is done, finish it by writing all + if (block.state === 'done') { + const { startLine: finalStartLine, endLine: finalEndLine } = addedTrackingZoneOfBlockNum[blockNum] + this._writeText(uri, block.final, + { startLineNumber: finalStartLine, startColumn: 1, endLineNumber: finalEndLine, endColumn: Number.MAX_SAFE_INTEGER }, // 1-indexed + { shouldRealignDiffAreas: true } + ) + diffZone._streamState.line = finalEndLine + 1 + currStreamingBlockNum = blockNum + 1 + continue + } + + // write the added text to the file + const deltaFinalText = block.final.substring((oldBlocks[blockNum]?.final ?? '').length, Infinity) + this._writeStreamedDiffZoneLLMText(uri, block.orig, block.final, deltaFinalText, latestStreamLocationMutable) + oldBlocks = blocks // oldblocks is only used if writingFinal + + // const { endLine: currentEndLine } = addedTrackingZoneOfBlockNum[blockNum] // would be bad to do this because a lot of the bottom lines might be the same. more accurate to go with latestStreamLocationMutable + // diffZone._streamState.line = currentEndLine + diffZone._streamState.line = latestStreamLocationMutable.line + + + + } // end for + + this._refreshStylesAndDiffsInURI(uri) + }, + onFinalMessage: async ({ fullText }) => { + console.log('final message!!', fullText) + + // 1. wait 500ms and fix lint errors - call lint error workflow + // (update react state to say "Fixing errors") + const blocks = extractSearchReplaceBlocks(fullText) + + if (blocks.length === 0) { + this._notificationService.info(`Void: We ran Apply, but the LLM didn't output any changes.`) + } + + // writeover the whole file + let newCode = originalFileCode + for (let blockNum = addedTrackingZoneOfBlockNum.length - 1; blockNum >= 0; blockNum -= 1) { + const { originalBounds } = addedTrackingZoneOfBlockNum[blockNum].metadata + const finalCode = blocks[blockNum].final + + if (finalCode === null) continue + + const [originalStart, originalEnd] = originalBounds + const lines = newCode.split('\n') + newCode = [ + ...lines.slice(0, (originalStart - 1)), + ...finalCode.split('\n'), + ...lines.slice((originalEnd - 1) + 1, Infinity) + ].join('\n') + } + const numLines = this._getNumLines(uri) + if (numLines !== null) { + this._writeText(uri, newCode, + { startLineNumber: 1, startColumn: 1, endLineNumber: numLines, endColumn: Number.MAX_SAFE_INTEGER }, + { shouldRealignDiffAreas: true } + ) + } + + onDone() + }, + onError: (e) => { + this._notifyError(e) + onDone() + this._undoHistory(uri) + }, + + }) + } + + + return diffZone + } + + + private _stopIfStreaming(diffZone: DiffZone) { const uri = diffZone._URI @@ -1620,29 +1730,73 @@ class EditCodeService extends Disposable implements IEditCodeService { this._llmMessageService.abort(streamRequestId) diffZone._streamState = { isStreaming: false, } - this._onDidChangeStreaming.fire({ uri, diffareaid: diffZone.diffareaid }) + this._onDidChangeDiffZoneStreaming.fire({ uri, diffareaid: diffZone.diffareaid }) } _undoHistory(uri: URI) { this._undoRedoService.undo(uri) } - // call this outside undo/redo (it calls undo). this is only for aborting a diffzone stream - interruptStreaming(diffareaid: number) { - const diffArea = this.diffAreaOfId[diffareaid] - if (!diffArea) return - if (diffArea.type !== 'DiffZone') return - if (!diffArea._streamState.isStreaming) return - this._stopIfStreaming(diffArea) - this._undoHistory(diffArea._URI) + + + _interruptSingleDiffZoneStreaming({ diffareaid }: { diffareaid: number }) { + const diffZone = this.diffAreaOfId[diffareaid] + if (diffZone?.type !== 'DiffZone') return + if (!diffZone._streamState.isStreaming) return + + this._stopIfStreaming(diffZone) + this._undoHistory(diffZone._URI) + } + + + isCtrlKZoneStreaming({ diffareaid }: { diffareaid: number }) { + const ctrlKZone = this.diffAreaOfId[diffareaid] + if (!ctrlKZone) return false + if (ctrlKZone.type !== 'CtrlKZone') return false + return !!ctrlKZone._linkedStreamingDiffZone + } + + + // diffareaid of the ctrlKZone (even though the stream state is dictated by the linked diffZone) + interruptCtrlKStreaming({ diffareaid }: { diffareaid: number }) { + const ctrlKZone = this.diffAreaOfId[diffareaid] + if (ctrlKZone?.type !== 'CtrlKZone') return + if (!ctrlKZone._linkedStreamingDiffZone) return + + const linkedStreamingDiffZone = this.diffAreaOfId[ctrlKZone._linkedStreamingDiffZone] + if (!linkedStreamingDiffZone) return + if (linkedStreamingDiffZone.type !== 'DiffZone') return + + this._interruptSingleDiffZoneStreaming({ diffareaid: linkedStreamingDiffZone.diffareaid }) } + getURIStreamState = ({ uri }: { uri: URI | null }) => { + if (uri === null) return 'idle' + + const diffZones = [...this.diffAreasOfURI[uri.fsPath].values()] + .map(diffareaid => this.diffAreaOfId[diffareaid]) + .filter(diffArea => !!diffArea && diffArea.type === 'DiffZone') + const isStreaming = diffZones.find(diffZone => !!diffZone._streamState.isStreaming) + const state: URIStreamState = isStreaming ? 'streaming' : (diffZones.length === 0 ? 'idle' : 'acceptRejectAll') + return state + } + + interruptURIStreaming({ uri }: { uri: URI }) { + // brute force for now is OK + for (const diffareaid of this.diffAreasOfURI[uri.fsPath] || []) { + const diffArea = this.diffAreaOfId[diffareaid] + if (diffArea?.type !== 'DiffZone') continue + if (!diffArea._streamState.isStreaming) continue + this._stopIfStreaming(diffArea) + } + this._undoHistory(uri) + } // public removeDiffZone(diffZone: DiffZone, behavior: 'reject' | 'accept') { diff --git a/src/vs/workbench/contrib/void/browser/helpers/extractCodeFromResult.ts b/src/vs/workbench/contrib/void/browser/helpers/extractCodeFromResult.ts index b7665eca74c..00eb2ef151c 100644 --- a/src/vs/workbench/contrib/void/browser/helpers/extractCodeFromResult.ts +++ b/src/vs/workbench/contrib/void/browser/helpers/extractCodeFromResult.ts @@ -3,6 +3,7 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ +import { OnText } from '../../common/llmMessageTypes.js' import { DIVIDER, FINAL, ORIGINAL } from '../prompt/prompts.js' class SurroundingsRemover { @@ -59,7 +60,7 @@ class SurroundingsRemover { // return offset === suffix.length // } - removeFromStartUntil = (until: string, alsoRemoveUntilStr: boolean) => { + removeFromStartUntilFullMatch = (until: string, alsoRemoveUntilStr: boolean) => { const index = this.originalS.indexOf(until, this.i) if (index === -1) { @@ -86,7 +87,7 @@ class SurroundingsRemover { const foundCodeBlock = pm.removePrefix('```') if (!foundCodeBlock) return false - pm.removeFromStartUntil('\n', true) // language + pm.removeFromStartUntilFullMatch('\n', true) // language const j = pm.j let foundCodeBlockEnd = pm.removeSuffix('```') @@ -159,27 +160,10 @@ export const extractCodeFromFIM = ({ text, recentlyAddedTextLen, midTag, }: { te const [delta, ignoredSuffix] = pm.deltaInfo(recentlyAddedTextLen) return [s, delta, ignoredSuffix] - - - // // const regex = /[\s\S]*?(?:`{1,3}\s*([a-zA-Z_]+[\w]*)?[\s\S]*?)?([\s\S]*?)(?:<\/MID>|`{1,3}|$)/; - // const regex = new RegExp( - // `[\\s\\S]*?(?:\`{1,3}\\s*([a-zA-Z_]+[\\w]*)?[\\s\\S]*?)?<${midTag}>([\\s\\S]*?)(?:|\`{1,3}|$)`, - // '' - // ); - // const match = text.match(regex); - // if (match) { - // const [_, languageName, codeBetweenMidTags] = match; - // return [languageName, codeBetweenMidTags] as const - - // } else { - // return [undefined, extractCodeFromRegular(text)] as const - // } - } - export type ExtractedSearchReplaceBlock = { state: 'writingOriginal' | 'writingFinal' | 'done', orig: string, @@ -201,7 +185,7 @@ export const extractSearchReplaceBlocks = (str: string) => { const ORIGINAL_ = ORIGINAL + `\n` const DIVIDER_ = '\n' + DIVIDER + `\n` - const FINAL_ = '\n' + FINAL + // logic for FINAL_ is slightly more complicated - should be '\n' + FINAL, but that ignores if the final output is empty const blocks: ExtractedSearchReplaceBlock[] = [] @@ -229,7 +213,13 @@ export const extractSearchReplaceBlocks = (str: string) => { i = dividerStart // wrote ===== - let finalStart = str.indexOf(FINAL_, i) + + + const finalStartA = str.indexOf(FINAL, i) + const finalStartB = str.indexOf('\n' + FINAL, i) // go with B if possible, else fallback to A, it's more permissive + const FINAL_ = finalStartB !== -1 ? '\n' + FINAL : FINAL + let finalStart = finalStartB !== -1 ? finalStartB : finalStartA + if (finalStart === -1) { // if didnt find FINAL_, either writing finalStr or FINAL_ right now const isWritingFINAL = endsWithAnyPrefixOf(str, FINAL_) blocks.push({ @@ -251,3 +241,96 @@ export const extractSearchReplaceBlocks = (str: string) => { }) } } + + + + + + + + + +// could simplify this - this assumes we can never add a tag without committing it to the user's screen, but that's not true +export const extractReasoningFromText = ( + onText_: OnText, + thinkTags: [string, string], +): OnText => { + + let latestAddIdx = 0 // exclusive + let foundTag1 = false + let foundTag2 = false + + let fullText = '' + let fullReasoning = '' + + const onText: OnText = ({ newText: newText_, fullText: fullText_ }) => { + // abcdefghi + // | + // until found the first think tag, keep adding to fullText + if (!foundTag1) { + const endsWithTag1 = endsWithAnyPrefixOf(fullText_, thinkTags[0]) + if (endsWithTag1) { + // wait until we get the full tag or know more + return + } + // if found the first tag + const tag1Index = fullText_.lastIndexOf(thinkTags[0]) + if (tag1Index !== -1) { + foundTag1 = true + const newText = fullText.substring(latestAddIdx, tag1Index) + const newReasoning = fullText.substring(tag1Index + thinkTags[0].length, Infinity) + + fullText += newText + fullReasoning += newReasoning + latestAddIdx += newText.length + newReasoning.length + onText_({ newText, fullText, newReasoning: newReasoning, fullReasoning }) + return + } + + // add the text to fullText + const newText = fullText.substring(latestAddIdx, Infinity) + fullText += newText + latestAddIdx += newText.length + onText_({ newText, fullText, newReasoning: '', fullReasoning }) + return + } + // at this point, we found + + // until found the second think tag, keep adding to fullReasoning + if (!foundTag2) { + const endsWithTag2 = endsWithAnyPrefixOf(fullText_, thinkTags[1]) + if (endsWithTag2) { + // wait until we get the full tag or know more + return + } + // if found the second tag + const tag2Index = fullText_.lastIndexOf(thinkTags[1]) + if (tag2Index !== -1) { + foundTag2 = true + const newReasoning = fullText.substring(latestAddIdx, tag2Index) + const newText = fullText.substring(tag2Index + thinkTags[1].length, Infinity) + + fullText += newText + fullReasoning += newReasoning + latestAddIdx += newText.length + newReasoning.length + onText_({ newText, fullText, newReasoning: newReasoning, fullReasoning }) + return + } + + // add the text to fullReasoning + const newReasoning = fullText.substring(latestAddIdx, Infinity) + fullReasoning += newReasoning + latestAddIdx += newReasoning.length + onText_({ newText: '', fullText, newReasoning, fullReasoning }) + return + } + // at this point, we found + + fullText += newText_ + const newText = fullText.substring(latestAddIdx, Infinity) + latestAddIdx += newText.length + onText_({ newText, fullText, newReasoning: '', fullReasoning }) + } + + return onText +} diff --git a/src/vs/workbench/contrib/void/browser/prompt/prompts.ts b/src/vs/workbench/contrib/void/browser/prompt/prompts.ts index f04fcb5cc5f..90e01d50f09 100644 --- a/src/vs/workbench/contrib/void/browser/prompt/prompts.ts +++ b/src/vs/workbench/contrib/void/browser/prompt/prompts.ts @@ -7,10 +7,9 @@ import { URI } from '../../../../../base/common/uri.js'; import { filenameToVscodeLanguage } from '../helpers/detectLanguage.js'; import { CodeSelection, StagingSelectionItem, FileSelection } from '../chatThreadService.js'; -import { VSReadFile } from '../helpers/readFile.js'; import { IModelService } from '../../../../../editor/common/services/model.js'; import { os } from '../helpers/systemInfo.js'; -import { IFileService } from '../../../../../platform/files/common/files.js'; +import { IVoidFileService } from '../../common/voidFileService.js'; // this is just for ease of readability @@ -169,10 +168,10 @@ ${tripleTick[1]} } const failToReadStr = 'Could not read content. This file may have been deleted. If you expected content here, you can tell the user about this as they might not know.' -const stringifyFileSelections = async (fileSelections: FileSelection[], modelService: IModelService, fileService: IFileService) => { +const stringifyFileSelections = async (fileSelections: FileSelection[], voidFileService: IVoidFileService) => { if (fileSelections.length === 0) return null const fileSlns: FileSelnLocal[] = await Promise.all(fileSelections.map(async (sel) => { - const content = await VSReadFile(sel.fileURI, modelService, fileService) ?? failToReadStr + const content = await voidFileService.readFile(sel.fileURI) ?? failToReadStr return { ...sel, content } })) return fileSlns.map(sel => stringifyFileSelection(sel)).join('\n') @@ -195,7 +194,7 @@ export const chat_userMessageContent = async (instructions: string, currSelns: S return str; }; -export const chat_selectionsString = async (prevSelns: StagingSelectionItem[] | null, currSelns: StagingSelectionItem[] | null, modelService: IModelService, fileService: IFileService) => { +export const chat_selectionsString = async (prevSelns: StagingSelectionItem[] | null, currSelns: StagingSelectionItem[] | null, voidFileService: IVoidFileService) => { // ADD IN FILES AT TOP const allSelections = [...currSelns || [], ...prevSelns || []] @@ -220,7 +219,7 @@ export const chat_selectionsString = async (prevSelns: StagingSelectionItem[] | } } - const filesStr = await stringifyFileSelections(fileSelections, modelService, fileService) + const filesStr = await stringifyFileSelections(fileSelections, voidFileService) const selnsStr = stringifyCodeSelections(codeSelections) @@ -297,12 +296,12 @@ For example, if the user is asking you to "make this variable a better name", ma - Make sure you give enough context in the code block to apply the changes to the correct location in the code` -export const aiRegex_computeReplacementsForFile_userMessage = async ({ searchClause, replaceClause, fileURI, modelService, fileService }: { searchClause: string, replaceClause: string, fileURI: URI, modelService: IModelService, fileService: IFileService }) => { +export const aiRegex_computeReplacementsForFile_userMessage = async ({ searchClause, replaceClause, fileURI, voidFileService }: { searchClause: string, replaceClause: string, fileURI: URI, modelService: IModelService, voidFileService: IVoidFileService }) => { // we may want to do this in batches const fileSelection: FileSelection = { type: 'File', fileURI, selectionStr: null, range: null } - const file = await stringifyFileSelections([fileSelection], modelService, fileService) + const file = await stringifyFileSelections([fileSelection], voidFileService) return `\ ## FILE diff --git a/src/vs/workbench/contrib/void/browser/quickEditActions.ts b/src/vs/workbench/contrib/void/browser/quickEditActions.ts index 1099e74cbfb..da8f5c55bf1 100644 --- a/src/vs/workbench/contrib/void/browser/quickEditActions.ts +++ b/src/vs/workbench/contrib/void/browser/quickEditActions.ts @@ -17,7 +17,6 @@ import { IMetricsService } from '../common/metricsService.js'; export type QuickEditPropsType = { diffareaid: number, - initStreamingDiffZoneId: number | null, textAreaRef: (ref: HTMLTextAreaElement | null) => void; onChangeHeight: (height: number) => void; onChangeText: (text: string) => void; diff --git a/src/vs/workbench/contrib/void/browser/react/src/markdown/ApplyBlockHoverButtons.tsx b/src/vs/workbench/contrib/void/browser/react/src/markdown/ApplyBlockHoverButtons.tsx new file mode 100644 index 00000000000..b31bfb7b422 --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/react/src/markdown/ApplyBlockHoverButtons.tsx @@ -0,0 +1,156 @@ +import { useState, useEffect, useCallback } from 'react' +import { useAccessor, useURIStreamState, useSettingsState } from '../util/services.js' +import { useRefState } from '../util/helpers.js' +import { isFeatureNameDisabled } from '../../../../common/voidSettingsTypes.js' +import { URI } from '../../../../../../../base/common/uri.js' +import { IEditCodeService, URIStreamState } from '../../../editCodeService.js' + +enum CopyButtonText { + Idle = 'Copy', + Copied = 'Copied!', + Error = 'Could not copy', +} + +const COPY_FEEDBACK_TIMEOUT = 1000 // amount of time to say 'Copied!' + +const CopyButton = ({ codeStr }: { codeStr: string }) => { + const accessor = useAccessor() + + const metricsService = accessor.get('IMetricsService') + const clipboardService = accessor.get('IClipboardService') + const [copyButtonText, setCopyButtonText] = useState(CopyButtonText.Idle) + + useEffect(() => { + if (copyButtonText === CopyButtonText.Idle) return + setTimeout(() => { + setCopyButtonText(CopyButtonText.Idle) + }, COPY_FEEDBACK_TIMEOUT) + }, [copyButtonText]) + + + const onCopy = useCallback(() => { + clipboardService.writeText(codeStr) + .then(() => { setCopyButtonText(CopyButtonText.Copied) }) + .catch(() => { setCopyButtonText(CopyButtonText.Error) }) + metricsService.capture('Copy Code', { length: codeStr.length }) // capture the length only + }, [metricsService, clipboardService, codeStr, setCopyButtonText]) + + const isSingleLine = !codeStr.includes('\n') + + return +} + + + + + +// state persisted for duration of react only +const applyingURIOfApplyBoxIdRef: { current: { [applyBoxId: string]: URI | undefined } } = { current: {} } + + + +export const ApplyBlockHoverButtons = ({ codeStr, applyBoxId }: { codeStr: string, applyBoxId: string }) => { + + console.log('applyboxid', applyBoxId, applyingURIOfApplyBoxIdRef) + + const settingsState = useSettingsState() + const isDisabled = !!isFeatureNameDisabled('Apply', settingsState) || !applyBoxId + + const accessor = useAccessor() + const editCodeService = accessor.get('IEditCodeService') + const metricsService = accessor.get('IMetricsService') + + const [_, rerender] = useState(0) + + const applyingUri = useCallback(() => applyingURIOfApplyBoxIdRef.current[applyBoxId] ?? null, [applyBoxId]) + const streamState = useCallback(() => editCodeService.getURIStreamState({ uri: applyingUri() }), [editCodeService, applyingUri]) + + // listen for stream updates + useURIStreamState( + useCallback((uri, newStreamState) => { + const shouldUpdate = applyingUri()?.fsPath !== uri.fsPath + if (shouldUpdate) return + rerender(c => c + 1) + }, [applyBoxId, editCodeService, applyingUri]) + ) + + const onSubmit = useCallback(() => { + if (isDisabled) return + if (streamState() === 'streaming') return + const newApplyingUri = editCodeService.startApplying({ + from: 'ClickApply', + type: 'searchReplace', + applyStr: codeStr, + }) + applyingURIOfApplyBoxIdRef.current[applyBoxId] = newApplyingUri ?? undefined + rerender(c => c + 1) + metricsService.capture('Apply Code', { length: codeStr.length }) // capture the length only + }, [isDisabled, streamState, editCodeService, codeStr, applyBoxId, metricsService]) + + + const onInterrupt = useCallback(() => { + if (streamState() !== 'streaming') return + const uri = applyingUri() + if (!uri) return + + editCodeService.interruptURIStreaming({ uri }) + metricsService.capture('Stop Apply', {}) + }, [streamState, applyingUri, editCodeService, metricsService]) + + + const isSingleLine = !codeStr.includes('\n') + + const applyButton = + + const stopButton = + + const acceptRejectButtons = <> + + + + + console.log('streamStateRef.current', streamState()) + + const currStreamState = streamState() + return <> + {currStreamState !== 'streaming' && } + {currStreamState === 'idle' && !isDisabled && applyButton} + {currStreamState === 'streaming' && stopButton} + {currStreamState === 'acceptRejectAll' && acceptRejectButtons} + +} diff --git a/src/vs/workbench/contrib/void/browser/react/src/markdown/ChatMarkdownRender.tsx b/src/vs/workbench/contrib/void/browser/react/src/markdown/ChatMarkdownRender.tsx index 3e77a6dfaad..8168bed31cb 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/markdown/ChatMarkdownRender.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/markdown/ChatMarkdownRender.tsx @@ -3,22 +3,12 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import React, { JSX, useCallback, useEffect, useState } from 'react' +import React, { JSX } from 'react' import { marked, MarkedToken, Token } from 'marked' import { BlockCode } from './BlockCode.js' -import { useAccessor, useChatThreadsState, useChatThreadsStreamState } from '../util/services.js' import { ChatMessageLocation, } from '../../../aiRegexService.js' import { nameToVscodeLanguage } from '../../../helpers/detectLanguage.js' - - -enum CopyButtonState { - Copy = 'Copy', - Copied = 'Copied!', - Error = 'Could not copy', -} - -const COPY_FEEDBACK_TIMEOUT = 1000 // amount of time to say 'Copied!' - +import { ApplyBlockHoverButtons } from './ApplyBlockHoverButtons.js' type ApplyBoxLocation = ChatMessageLocation & { tokenIdx: string } @@ -29,60 +19,6 @@ const getApplyBoxId = ({ threadId, messageIdx, tokenIdx }: ApplyBoxLocation) => -const ApplyButtonsOnHover = ({ applyStr }: { applyStr: string }) => { - const accessor = useAccessor() - - const [copyButtonState, setCopyButtonState] = useState(CopyButtonState.Copy) - const editCodeService = accessor.get('IEditCodeService') - const clipboardService = accessor.get('IClipboardService') - const metricsService = accessor.get('IMetricsService') - - useEffect(() => { - - if (copyButtonState !== CopyButtonState.Copy) { - setTimeout(() => { - setCopyButtonState(CopyButtonState.Copy) - }, COPY_FEEDBACK_TIMEOUT) - } - }, [copyButtonState]) - - const onCopy = useCallback(() => { - clipboardService.writeText(applyStr) - .then(() => { setCopyButtonState(CopyButtonState.Copied) }) - .catch(() => { setCopyButtonState(CopyButtonState.Error) }) - metricsService.capture('Copy Code', { length: applyStr.length }) // capture the length only - - }, [metricsService, clipboardService, applyStr]) - - const onApply = useCallback(() => { - - editCodeService.startApplying({ - from: 'ClickApply', - type: 'searchReplace', - applyStr, - }) - metricsService.capture('Apply Code', { length: applyStr.length }) // capture the length only - }, [metricsService, editCodeService, applyStr]) - - const isSingleLine = !applyStr.includes('\n') - - return <> - - - -} - export const CodeSpan = ({ children, className }: { children: React.ReactNode, className?: string }) => { return } -const RenderToken = ({ token, nested = false, noSpace = false, chatMessageLocation, tokenIdx }: { token: Token | string, nested?: boolean, noSpace?: boolean, chatMessageLocation?: ChatMessageLocation, tokenIdx: string }): JSX.Element => { +const RenderToken = ({ token, nested, noSpace, chatMessageLocation, tokenIdx }: { token: Token | string, nested?: boolean, noSpace?: boolean, chatMessageLocation?: ChatMessageLocation, tokenIdx: string }): JSX.Element => { // deal with built-in tokens first (assume marked token) @@ -108,9 +44,7 @@ const RenderToken = ({ token, nested = false, noSpace = false, chatMessageLocati } if (t.type === "code") { - const isCodeblockClosed = t.raw?.startsWith('```') && t.raw?.endsWith('```'); - // this should never be const applyBoxId = chatMessageLocation ? getApplyBoxId({ threadId: chatMessageLocation.threadId, messageIdx: chatMessageLocation.messageIdx, @@ -120,7 +54,7 @@ const RenderToken = ({ token, nested = false, noSpace = false, chatMessageLocati return } + buttonsOnHover={applyBoxId && } /> } @@ -201,6 +135,24 @@ const RenderToken = ({ token, nested = false, noSpace = false, chatMessageLocati ))} ) + // attempt at indentation + // return ( + // + // {t.items.map((item, index) => ( + //
  • + // {item.task && ( + // + // )} + // + // + // + //
  • + // ))} + //
    + // ) } if (t.type === "paragraph") { diff --git a/src/vs/workbench/contrib/void/browser/react/src/quick-edit-tsx/QuickEditChat.tsx b/src/vs/workbench/contrib/void/browser/react/src/quick-edit-tsx/QuickEditChat.tsx index f79c9af96b5..fe70caa3019 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/quick-edit-tsx/QuickEditChat.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/quick-edit-tsx/QuickEditChat.tsx @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------*/ import React, { FormEvent, useCallback, useEffect, useRef, useState } from 'react'; -import { useSettingsState, useSidebarState, useChatThreadsState, useQuickEditState, useAccessor } from '../util/services.js'; +import { useSettingsState, useSidebarState, useChatThreadsState, useQuickEditState, useAccessor, useCtrlKZoneStreamingState } from '../util/services.js'; import { TextAreaFns, VoidInputBox2 } from '../util/inputs.js'; import { QuickEditPropsType } from '../../../quickEditActions.js'; import { ButtonStop, ButtonSubmit, IconX, VoidChatArea } from '../sidebar-tsx/SidebarChat.js'; @@ -16,7 +16,6 @@ import { isFeatureNameDisabled } from '../../../../../../../workbench/contrib/vo export const QuickEditChat = ({ diffareaid, - initStreamingDiffZoneId, onChangeHeight, onChangeText: onChangeText_, textAreaRef: textAreaRef_, @@ -49,28 +48,31 @@ export const QuickEditChat = ({ const [instructionsAreEmpty, setInstructionsAreEmpty] = useState(!(initText ?? '')) // the user's instructions const isDisabled = instructionsAreEmpty || !!isFeatureNameDisabled('Ctrl+K', settingsState) - const [currStreamingDiffZoneRef, setCurrentlyStreamingDiffZone] = useRefState(initStreamingDiffZoneId) - const isStreaming = currStreamingDiffZoneRef.current !== null + + const [isStreamingRef, setIsStreamingRef] = useRefState(editCodeService.isCtrlKZoneStreaming({ diffareaid })) + useCtrlKZoneStreamingState(useCallback((diffareaid2, isStreaming) => { + if (diffareaid !== diffareaid2) return + setIsStreamingRef(isStreaming) + }, [diffareaid, setIsStreamingRef])) + const onSubmit = useCallback(() => { if (isDisabled) return - if (currStreamingDiffZoneRef.current !== null) return + if (isStreamingRef.current) return textAreaFnsRef.current?.disable() - const id = editCodeService.startApplying({ + editCodeService.startApplying({ from: 'QuickEdit', - type:'rewrite', - diffareaid: diffareaid, + type: 'rewrite', + diffareaid, }) - setCurrentlyStreamingDiffZone(id ?? null) - }, [currStreamingDiffZoneRef, setCurrentlyStreamingDiffZone, isDisabled, editCodeService, diffareaid]) + }, [isStreamingRef, isDisabled, editCodeService, diffareaid]) const onInterrupt = useCallback(() => { - if (currStreamingDiffZoneRef.current === null) return - editCodeService.interruptStreaming(currStreamingDiffZoneRef.current) - setCurrentlyStreamingDiffZone(null) + if (!isStreamingRef.current) return + editCodeService.interruptCtrlKStreaming({ diffareaid }) textAreaFnsRef.current?.enable() - }, [currStreamingDiffZoneRef, setCurrentlyStreamingDiffZone, editCodeService]) + }, [isStreamingRef, editCodeService]) const onX = useCallback(() => { @@ -89,7 +91,7 @@ export const QuickEditChat = ({ onSubmit={onSubmit} onAbort={onInterrupt} onClose={onX} - isStreaming={isStreaming} + isStreaming={isStreamingRef.current} isDisabled={isDisabled} featureName="Ctrl+K" className="py-2 w-full" diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx index e9169280af2..979ae67ba3e 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx @@ -7,7 +7,7 @@ import React, { ButtonHTMLAttributes, FormEvent, FormHTMLAttributes, Fragment, K import { useAccessor, useSidebarState, useChatThreadsState, useChatThreadsStreamState, useUriState, useSettingsState } from '../util/services.js'; -import { ChatMessage, StagingSelectionItem } from '../../../chatThreadService.js'; +import { ChatMessage, StagingSelectionItem, ToolMessage } from '../../../chatThreadService.js'; import { BlockCode } from '../markdown/BlockCode.js'; import { ChatMarkdownRender } from '../markdown/ChatMarkdownRender.js'; @@ -21,10 +21,11 @@ import { useScrollbarStyles } from '../util/useScrollbarStyles.js'; import { VOID_CTRL_L_ACTION_ID } from '../../../actionIDs.js'; import { filenameToVscodeLanguage } from '../../../helpers/detectLanguage.js'; import { VOID_OPEN_SETTINGS_ACTION_ID } from '../../../voidSettingsPane.js'; -import { Pencil, X } from 'lucide-react'; +import { ChevronRight, Pencil, X } from 'lucide-react'; import { FeatureName, isFeatureNameDisabled } from '../../../../../../../workbench/contrib/void/common/voidSettingsTypes.js'; import { WarningBox } from '../void-settings-tsx/WarningBox.js'; import { ChatMessageLocation } from '../../../aiRegexService.js'; +import { ToolCallReturnType, ToolName } from '../../../../common/toolsService.js'; @@ -542,6 +543,146 @@ export const SelectedFiles = ( } +type ToolReusltToComponent = { [T in ToolName]: (props: { message: ToolMessage }) => React.ReactNode } +interface ToolResultProps { + actionTitle: string; + actionParam: string; + actionNumResults?: number; + children?: React.ReactNode; +} + +const ToolResult = ({ + actionTitle, + actionParam, + actionNumResults, + children, +}: ToolResultProps) => { + const [isExpanded, setIsExpanded] = useState(false); + + const isDropdown = !!children + + return ( +
    +
    +
    children && setIsExpanded(!isExpanded)} + > + {isDropdown && ( + + )} +
    + {actionTitle} + {`"`}{actionParam}{`"`} + {actionNumResults !== undefined && ( + + {`(`}{actionNumResults}{` result`}{actionNumResults !== 1 ? 's' : ''}{`)`} + + )} +
    +
    +
    + {children} +
    +
    +
    + ); +}; + + + +const toolResultToComponent: ToolReusltToComponent = { + 'read_file': ({ message }) => ( + + ), + 'list_dir': ({ message }) => ( + +
    + {message.result.children?.map((item, i) => ( +
    + {item.name} + {item.isDirectory && '/'} +
    + ))} + {message.result.hasNextPage && ( +
    + {message.result.itemsRemaining} more items... +
    + )} +
    +
    + ), + 'pathname_search': ({ message }) => ( + +
    + {Array.isArray(message.result.uris) ? + message.result.uris.map((uri, i) => ( + + )) : +
    {message.result.uris}
    + } + {message.result.hasNextPage && ( +
    + More results available... +
    + )} +
    +
    + ), + 'search': ({ message }) => ( + +
    + {typeof message.result.uris === 'string' ? + message.result.uris : + message.result.uris.map((uri, i) => ( + + )) + } + {message.result.hasNextPage && ( +
    + More results available... +
    + )} +
    +
    + ) +}; + + + type ChatBubbleMode = 'display' | 'edit' const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatMessage, messageIdx?: number, isLoading?: boolean, }) => { @@ -552,16 +693,16 @@ const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatM // global state let isBeingEdited = false - let setIsBeingEdited = (v: boolean) => { } let stagingSelections: StagingSelectionItem[] = [] - let setStagingSelections = (s: StagingSelectionItem[]) => { } + let setIsBeingEdited = (_: boolean) => { } + let setStagingSelections = (_: StagingSelectionItem[]) => { } if (messageIdx !== undefined) { - const [_state, _setState] = chatThreadsService._useCurrentMessageState(messageIdx) + const _state = chatThreadsService.getCurrentMessageState(messageIdx) isBeingEdited = _state.isBeingEdited - setIsBeingEdited = (v) => _setState({ isBeingEdited: v }) stagingSelections = _state.stagingSelections - setStagingSelections = (s) => { _setState({ stagingSelections: s }) } + setIsBeingEdited = (v) => chatThreadsService.setCurrentMessageState(messageIdx, { isBeingEdited: v }) + setStagingSelections = (s) => chatThreadsService.setCurrentMessageState(messageIdx, { stagingSelections: s }) } @@ -590,7 +731,7 @@ const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatM _mustInitialize.current = false } - }, [role, mode, _justEnabledEdit, textAreaRefState, textAreaFnsRef.current, _justEnabledEdit.current, _mustInitialize.current]) + }, [chatMessage, role, mode, _justEnabledEdit, textAreaRefState, textAreaFnsRef.current, _justEnabledEdit.current, _mustInitialize.current]) const EditSymbol = mode === 'display' ? Pencil : X const onOpenEdit = () => { setIsBeingEdited(true) @@ -631,7 +772,7 @@ const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatM // stream the edit const userMessage = textAreaRefState.value; - await chatThreadsService.editUserMessageAndStreamResponse({ userMessage, chatMode: 'agent', messageIdx }) + await chatThreadsService.editUserMessageAndStreamResponse({ userMessage, chatMode: 'agent', messageIdx, }) } const onAbort = () => { @@ -695,7 +836,13 @@ const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatM chatbubbleContents = } else if (role === 'tool') { - chatbubbleContents = chatMessage.name + + const ToolComponent = toolResultToComponent[chatMessage.name] as ({ message }: { message: any }) => React.ReactNode // ts isnt smart enough to deal with the types here... + + chatbubbleContents = + + console.log('tool result:', chatMessage.name, chatMessage.params, chatMessage.result) + } return
    { const currentThread = chatThreadsService.getCurrentThread() const previousMessages = currentThread?.messages ?? [] - const [_state, _setState] = chatThreadsService._useCurrentThreadState() - const selections = _state.stagingSelections - const setSelections = (s: StagingSelectionItem[]) => { _setState({ stagingSelections: s }) } + const selections = chatThreadsService.getCurrentThread().state.stagingSelections + const setSelections = (s: StagingSelectionItem[]) => { chatThreadsService.setCurrentThreadStagingSelections(s) } // stream state const currThreadStreamState = useChatThreadsStreamState(chatThreadsState.currentThreadId) @@ -818,7 +964,7 @@ export const SidebarChat = () => { textAreaFnsRef.current?.setValue('') textAreaRef.current?.focus() // focus input after submit - }, [chatThreadsService, isDisabled, isStreaming, textAreaRef, textAreaFnsRef, selections, setSelections]) + }, [chatThreadsService, isDisabled, isStreaming, textAreaRef, textAreaFnsRef, setSelections]) const onAbort = () => { const threadId = currentThread.id @@ -874,7 +1020,7 @@ export const SidebarChat = () => { {/* error message */} {latestError === undefined ? null : -
    +
    { let firstMsg = null; // let secondMsg = null; - const firstMsgIdx = pastThread.messages.findIndex( - (msg) => msg.role !== 'system' && !!msg.displayContent + const firstUserMsgIdx = pastThread.messages.findIndex( + (msg) => msg.role !== 'system' && msg.role !== 'tool' && !!msg.displayContent ); - if (firstMsgIdx !== -1) { + if (firstUserMsgIdx !== -1) { // firstMsg = truncate(pastThread.messages[firstMsgIdx].displayContent ?? ''); - firstMsg = pastThread.messages[firstMsgIdx].displayContent ?? ''; + const firsUsertMsgObj = pastThread.messages[firstUserMsgIdx] + firstMsg = firsUsertMsgObj.role === 'user' && firsUsertMsgObj.displayContent || ''; } else { firstMsg = '""'; } diff --git a/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx b/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx index e62e7a9e9af..be327655b00 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx @@ -152,12 +152,13 @@ export const VoidInputBox2 = forwardRef(fun }) -export const VoidInputBox = ({ onChangeText, onCreateInstance, inputBoxRef, placeholder, multiline }: { +export const VoidInputBox = ({ onChangeText, onCreateInstance, inputBoxRef, placeholder, isPasswordField, multiline }: { onChangeText: (value: string) => void; styles?: Partial, onCreateInstance?: (instance: InputBox) => void | IDisposable[]; inputBoxRef?: { current: InputBox | null }; placeholder: string; + isPasswordField?: boolean; multiline: boolean; }) => { @@ -182,6 +183,7 @@ export const VoidInputBox = ({ onChangeText, onCreateInstance, inputBoxRef, plac }, placeholder, tooltip: '', + type: isPasswordField ? 'password' : undefined, flexibleHeight: multiline, flexibleMaxHeight: 500, flexibleWidth: false, @@ -711,7 +713,7 @@ export const VoidCodeEditor = ({ initValue, language, maxHeight, showScrollbars onCreateInstance={useCallback((editor: CodeEditorWidget) => { const model = modelOfEditorId[id] ?? modelService.createModel( - initValueRef.current, { + initValueRef.current + '\n', { languageId: languageRef.current ? languageRef.current : 'typescript', onDidChange: (e) => { return { dispose: () => { } } } // no idea why they'd require this }) diff --git a/src/vs/workbench/contrib/void/browser/react/src/util/services.tsx b/src/vs/workbench/contrib/void/browser/react/src/util/services.tsx index 2d516ace051..5e1644283ac 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/util/services.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/util/services.tsx @@ -3,7 +3,7 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useCallback } from 'react' import { ThreadStreamState, ThreadsState } from '../../../chatThreadService.js' import { RefreshableProviderName, SettingsOfProvider } from '../../../../../../../workbench/contrib/void/common/voidSettingsTypes.js' import { IDisposable } from '../../../../../../../base/common/lifecycle.js' @@ -14,10 +14,6 @@ import { VoidUriState } from '../../../voidUriStateService.js'; import { VoidQuickEditState } from '../../../quickEditStateService.js' import { RefreshModelStateOfProvider } from '../../../../../../../workbench/contrib/void/common/refreshModelService.js' - - - - import { ServicesAccessor } from '../../../../../../../editor/browser/editorExtensions.js'; import { IModelService } from '../../../../../../../editor/common/services/model.js'; import { IClipboardService } from '../../../../../../../platform/clipboard/common/clipboardService.js'; @@ -28,7 +24,7 @@ import { IThemeService } from '../../../../../../../platform/theme/common/themeS import { ILLMMessageService } from '../../../../../../../workbench/contrib/void/common/llmMessageService.js'; import { IRefreshModelService } from '../../../../../../../workbench/contrib/void/common/refreshModelService.js'; import { IVoidSettingsService } from '../../../../../../../workbench/contrib/void/common/voidSettingsService.js'; -import { IEditCodeService } from '../../../editCodeService.js'; +import { IEditCodeService, URIStreamState } from '../../../editCodeService.js'; import { IVoidUriStateService } from '../../../voidUriStateService.js'; import { IQuickEditStateService } from '../../../quickEditStateService.js'; import { ISidebarStateService } from '../../../sidebarStateService.js'; @@ -47,6 +43,7 @@ import { IEnvironmentService } from '../../../../../../../platform/environment/c import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js' import { IPathService } from '../../../../../../../workbench/services/path/common/pathService.js' import { IMetricsService } from '../../../../../../../workbench/contrib/void/common/metricsService.js' +import { URI } from '../../../../../../../base/common/uri.js' @@ -79,6 +76,11 @@ const refreshModelProviderListeners: Set<(p: RefreshableProviderName, s: Refresh let colorThemeState: ColorScheme const colorThemeStateListeners: Set<(s: ColorScheme) => void> = new Set() +const ctrlKZoneStreamingStateListeners: Set<(diffareaid: number, s: boolean) => void> = new Set() +const uriStreamingStateListeners: Set<(uri: URI, s: URIStreamState) => void> = new Set() + + + // must call this before you can use any of the hooks below // this should only be called ONCE! this is the only place you don't need to dispose onDidChange. If you use state.onDidChange anywhere else, make sure to dispose it! let wasCalled = false @@ -162,7 +164,7 @@ export const _registerServices = (accessor: ServicesAccessor) => { refreshModelService.onDidChangeState((providerName) => { refreshModelState = refreshModelService.state refreshModelStateListeners.forEach(l => l(refreshModelState)) - refreshModelProviderListeners.forEach(l => l(providerName, refreshModelState)) + refreshModelProviderListeners.forEach(l => l(providerName, refreshModelState)) // no state }) ) @@ -174,6 +176,21 @@ export const _registerServices = (accessor: ServicesAccessor) => { }) ) + // no state + disposables.push( + editCodeService.onDidChangeCtrlKZoneStreaming(({ diffareaid }) => { + const isStreaming = editCodeService.isCtrlKZoneStreaming({ diffareaid }) + ctrlKZoneStreamingStateListeners.forEach(l => l(diffareaid, isStreaming)) + }) + ) + disposables.push( + editCodeService.onDidChangeURIStreamState(({ uri }) => { + const isStreaming = editCodeService.getURIStreamState({ uri }) + uriStreamingStateListeners.forEach(l => l(uri, isStreaming)) + }) + ) + + return disposables } @@ -336,7 +353,21 @@ export const useRefreshModelListener = (listener: (providerName: RefreshableProv useEffect(() => { refreshModelProviderListeners.add(listener) return () => { refreshModelProviderListeners.delete(listener) } - }, [listener]) + }, [listener, refreshModelProviderListeners]) +} + +export const useCtrlKZoneStreamingState = (listener: (diffareaid: number, s: boolean) => void) => { + useEffect(() => { + ctrlKZoneStreamingStateListeners.add(listener) + return () => { ctrlKZoneStreamingStateListeners.delete(listener) } + }, [listener, ctrlKZoneStreamingStateListeners]) +} + +export const useURIStreamState = (listener: (uri: URI, s: URIStreamState) => void) => { + useEffect(() => { + uriStreamingStateListeners.add(listener) + return () => { uriStreamingStateListeners.delete(listener) } + }, [listener, uriStreamingStateListeners]) } @@ -353,3 +384,4 @@ export const useIsDark = () => { return isDark } + diff --git a/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx b/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx index 0cfdef04442..7f28467f76d 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx @@ -5,7 +5,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { InputBox } from '../../../../../../../base/browser/ui/inputbox/inputBox.js' -import { ProviderName, SettingName, displayInfoOfSettingName, providerNames, VoidModelInfo, globalSettingNames, customSettingNamesOfProvider, RefreshableProviderName, refreshableProviderNames, displayInfoOfProviderName, defaultProviderSettings, nonlocalProviderNames, localProviderNames, GlobalSettingName, featureNames, displayInfoOfFeatureName, isProviderNameDisabled } from '../../../../common/voidSettingsTypes.js' +import { ProviderName, SettingName, displayInfoOfSettingName, providerNames, VoidModelInfo, globalSettingNames, customSettingNamesOfProvider, RefreshableProviderName, refreshableProviderNames, displayInfoOfProviderName, defaultProviderSettings, nonlocalProviderNames, localProviderNames, GlobalSettingName, featureNames, displayInfoOfFeatureName, isProviderNameDisabled, FeatureName } from '../../../../common/voidSettingsTypes.js' import ErrorBoundary from '../sidebar-tsx/ErrorBoundary.js' import { VoidButton, VoidCheckBox, VoidCustomDropdownBox, VoidInputBox, VoidInputBox2, VoidSwitch } from '../util/inputs.js' import { useAccessor, useIsDark, useRefreshModelListener, useRefreshModelState, useSettingsState } from '../util/services.js' @@ -21,7 +21,7 @@ import { os } from '../../../helpers/systemInfo.js' const SubtleButton = ({ onClick, text, icon, disabled }: { onClick: () => void, text: string, icon: React.ReactNode, disabled: boolean }) => { - return
    + return
    @@ -82,9 +82,7 @@ const RefreshableModels = () => { const buttons = refreshableProviderNames.map(providerName => { if (!settingsState.settingsOfProvider[providerName]._didFillInProviderSettings) return null - return
    - -
    + return }) return <> @@ -257,7 +255,7 @@ const ProviderSetting = ({ providerName, settingName }: { providerName: Provider // const { title: providerTitle, } = displayInfoOfProviderName(providerName) - const { title: settingTitle, placeholder, subTextMd } = displayInfoOfSettingName(providerName, settingName) + const { title: settingTitle, placeholder, isPasswordField, subTextMd } = displayInfoOfSettingName(providerName, settingName) const accessor = useAccessor() const voidSettingsService = accessor.get('IVoidSettingsService') @@ -269,6 +267,7 @@ const ProviderSetting = ({ providerName, settingName }: { providerName: Provider { if (weChangedTextRef) return voidSettingsService.setSettingOfProvider(providerName, settingName, newVal) @@ -291,6 +290,7 @@ const ProviderSetting = ({ providerName, settingName }: { providerName: Provider return [disposable] }, [voidSettingsService, providerName, settingName])} multiline={false} + isPasswordField={isPasswordField} /> {subTextMd === undefined ? null :
    @@ -339,7 +339,7 @@ const SettingsForProvider = ({ providerName }: { providerName: ProviderName }) = {needsModel ? providerName === 'ollama' ? - : + : : null}
    @@ -377,6 +377,7 @@ export const AutoRefreshToggle = () => { icon={enabled ? : } disabled={false} /> + } export const AIInstructionsBox = () => { @@ -400,6 +401,7 @@ export const FeaturesTab = () => { +
    @@ -413,7 +415,7 @@ export const FeaturesTab = () => {
    - + {/* TODO we should create UI for downloading models without user going into terminal */} @@ -435,12 +437,13 @@ export const FeaturesTab = () => {

    Feature Options

    {featureNames.map(featureName => -
    -

    {displayInfoOfFeatureName(featureName)}

    - -
    + (['Ctrl+L', 'Ctrl+K'] as FeatureName[]).includes(featureName) ? null : +
    +

    {displayInfoOfFeatureName(featureName)}

    + +
    )}
    @@ -624,7 +627,7 @@ export const Settings = () => {
    -

    Void Settings

    +

    {`Void's Settings`}

    {/* separator */}
    diff --git a/src/vs/workbench/contrib/void/browser/react/tailwind.config.js b/src/vs/workbench/contrib/void/browser/react/tailwind.config.js index c4dc19801bb..bc57116b1a9 100644 --- a/src/vs/workbench/contrib/void/browser/react/tailwind.config.js +++ b/src/vs/workbench/contrib/void/browser/react/tailwind.config.js @@ -28,17 +28,25 @@ module.exports = { colors: { "void-bg-1": "var(--vscode-input-background)", + "void-bg-1-alt": "var(--vscode-badge-background)", "void-bg-2": "var(--vscode-sideBar-background)", + "void-bg-2-alt": "color-mix(in srgb, var(--vscode-sideBar-background) 30%, var(--vscode-editor-background) 70%)", "void-bg-3": "var(--vscode-editor-background)", + "void-fg-1": "var(--vscode-editor-foreground)", "void-fg-2": "var(--vscode-input-foreground)", "void-fg-3": "var(--vscode-input-placeholderForeground)", + // "void-fg-4": "var(--vscode-tab-inactiveForeground)", + "void-fg-4": "var(--vscode-list-deemphasizedForeground)", + + "void-warning": "var(--vscode-charts-yellow)", "void-border-1": "var(--vscode-commandCenter-activeBorder)", "void-border-2": "var(--vscode-commandCenter-border)", "void-border-3": "var(--vscode-commandCenter-inactiveBorder)", + "void-border-3": "var(--vscode-settings-sashBorder)", vscode: { diff --git a/src/vs/workbench/contrib/void/browser/sidebarActions.ts b/src/vs/workbench/contrib/void/browser/sidebarActions.ts index 2e64c53f3d1..722eaa5703f 100644 --- a/src/vs/workbench/contrib/void/browser/sidebarActions.ts +++ b/src/vs/workbench/contrib/void/browser/sidebarActions.ts @@ -141,13 +141,11 @@ registerAction2(class extends Action2 { let setSelections = (s: StagingSelectionItem[]) => { } if (focusedMessageIdx === undefined) { - const [state, setState] = chatThreadService._useCurrentThreadState() - selections = state.stagingSelections - setSelections = (s) => setState({ stagingSelections: s }) + selections = chatThreadService.getCurrentThreadStagingSelections() + setSelections = (s: StagingSelectionItem[]) => chatThreadService.setCurrentThreadStagingSelections(s) } else { - const [state, setState] = chatThreadService._useCurrentMessageState(focusedMessageIdx) - selections = state.stagingSelections - setSelections = (s) => setState({ stagingSelections: s }) + selections = chatThreadService.getCurrentMessageState(focusedMessageIdx).stagingSelections + setSelections = (s) => chatThreadService.setCurrentMessageState(focusedMessageIdx, { stagingSelections: s }) } // if matches with existing selection, overwrite (since text may change) @@ -241,7 +239,7 @@ registerAction2(class extends Action2 { constructor() { super({ id: 'void.settingsAction', - title: 'Void Settings', + title: `Void's Settings`, icon: { id: 'settings-gear' }, menu: [{ id: MenuId.ViewTitle, group: 'navigation', when: ContextKeyExpr.equals('view', VOID_VIEW_ID), }] }); diff --git a/src/vs/workbench/contrib/void/browser/voidSettingsPane.ts b/src/vs/workbench/contrib/void/browser/voidSettingsPane.ts index 0fd8ce2e0d7..d5e99d577e5 100644 --- a/src/vs/workbench/contrib/void/browser/voidSettingsPane.ts +++ b/src/vs/workbench/contrib/void/browser/voidSettingsPane.ts @@ -49,7 +49,7 @@ class VoidSettingsInput extends EditorInput { } override getName(): string { - return nls.localize('voidSettingsInputsName', 'Void Settings'); + return nls.localize('voidSettingsInputsName', 'Void\'s Settings'); } override getIcon() { @@ -112,7 +112,7 @@ class VoidSettingsPane extends EditorPane { // register Settings pane Registry.as(EditorExtensions.EditorPane).registerEditorPane( - EditorPaneDescriptor.create(VoidSettingsPane, VoidSettingsPane.ID, nls.localize('VoidSettingsPane', "Void Settings Pane")), + EditorPaneDescriptor.create(VoidSettingsPane, VoidSettingsPane.ID, nls.localize('VoidSettingsPane', "Void\'s Settings Pane")), [new SyncDescriptor(VoidSettingsInput)] ); @@ -202,7 +202,7 @@ MenuRegistry.appendMenuItem(MenuId.GlobalActivity, { group: '0_command', command: { id: VOID_TOGGLE_SETTINGS_ACTION_ID, - title: nls.localize('voidSettings', "Void Settings") + title: nls.localize('voidSettings', "Void\'s Settings") }, order: 1 }); diff --git a/src/vs/workbench/contrib/void/common/llmMessageService.ts b/src/vs/workbench/contrib/void/common/llmMessageService.ts index 314031d4476..b2266213ea2 100644 --- a/src/vs/workbench/contrib/void/common/llmMessageService.ts +++ b/src/vs/workbench/contrib/void/common/llmMessageService.ts @@ -3,7 +3,7 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import { EventLLMMessageOnTextParams, EventLLMMessageOnErrorParams, EventLLMMessageOnFinalMessageParams, ServiceSendLLMMessageParams, MainSendLLMMessageParams, MainLLMMessageAbortParams, ServiceModelListParams, EventModelListOnSuccessParams, EventModelListOnErrorParams, MainModelListParams, OllamaModelResponse, OpenaiCompatibleModelResponse, } from './llmMessageTypes.js'; +import { EventLLMMessageOnTextParams, EventLLMMessageOnErrorParams, EventLLMMessageOnFinalMessageParams, ServiceSendLLMMessageParams, MainSendLLMMessageParams, MainLLMMessageAbortParams, ServiceModelListParams, EventModelListOnSuccessParams, EventModelListOnErrorParams, MainModelListParams, OllamaModelResponse, VLLMModelResponse, } from './llmMessageTypes.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; @@ -24,27 +24,39 @@ export interface ILLMMessageService { sendLLMMessage: (params: ServiceSendLLMMessageParams) => string | null; abort: (requestId: string) => void; ollamaList: (params: ServiceModelListParams) => void; - openAICompatibleList: (params: ServiceModelListParams) => void; + vLLMList: (params: ServiceModelListParams) => void; } + +// open this file side by side with llmMessageChannel export class LLMMessageService extends Disposable implements ILLMMessageService { readonly _serviceBrand: undefined; private readonly channel: IChannel // LLMMessageChannel - // llmMessage - private readonly onTextHooks_llm: { [eventId: string]: ((params: EventLLMMessageOnTextParams) => void) } = {} - private readonly onFinalMessageHooks_llm: { [eventId: string]: ((params: EventLLMMessageOnFinalMessageParams) => void) } = {} - private readonly onErrorHooks_llm: { [eventId: string]: ((params: EventLLMMessageOnErrorParams) => void) } = {} - - - // ollamaList - private readonly onSuccess_ollama: { [eventId: string]: ((params: EventModelListOnSuccessParams) => void) } = {} - private readonly onError_ollama: { [eventId: string]: ((params: EventModelListOnErrorParams) => void) } = {} + // sendLLMMessage + private readonly llmMessageHooks = { + onText: {} as { [eventId: string]: ((params: EventLLMMessageOnTextParams) => void) }, + onFinalMessage: {} as { [eventId: string]: ((params: EventLLMMessageOnFinalMessageParams) => void) }, + onError: {} as { [eventId: string]: ((params: EventLLMMessageOnErrorParams) => void) }, + } - // openAICompatibleList - private readonly onSuccess_openAICompatible: { [eventId: string]: ((params: EventModelListOnSuccessParams) => void) } = {} - private readonly onError_openAICompatible: { [eventId: string]: ((params: EventModelListOnErrorParams) => void) } = {} + // list hooks + private readonly listHooks = { + ollama: { + success: {} as { [eventId: string]: ((params: EventModelListOnSuccessParams) => void) }, + error: {} as { [eventId: string]: ((params: EventModelListOnErrorParams) => void) }, + }, + vLLM: { + success: {} as { [eventId: string]: ((params: EventModelListOnSuccessParams) => void) }, + error: {} as { [eventId: string]: ((params: EventModelListOnErrorParams) => void) }, + } + } satisfies { + [providerName: string]: { + success: { [eventId: string]: ((params: EventModelListOnSuccessParams) => void) }, + error: { [eventId: string]: ((params: EventModelListOnErrorParams) => void) }, + } + } constructor( @IMainProcessService private readonly mainProcessService: IMainProcessService, // used as a renderer (only usable on client side) @@ -59,32 +71,14 @@ export class LLMMessageService extends Disposable implements ILLMMessageService // .listen sets up an IPC channel and takes a few ms, so we set up listeners immediately and add hooks to them instead // llm - this._register((this.channel.listen('onText_llm') satisfies Event)(e => { - this.onTextHooks_llm[e.requestId]?.(e) - })) - this._register((this.channel.listen('onFinalMessage_llm') satisfies Event)(e => { - this.onFinalMessageHooks_llm[e.requestId]?.(e) - this._onRequestIdDone(e.requestId) - })) - this._register((this.channel.listen('onError_llm') satisfies Event)(e => { - console.error('Error in LLMMessageService:', JSON.stringify(e)) - this.onErrorHooks_llm[e.requestId]?.(e) - this._onRequestIdDone(e.requestId) - })) + this._register((this.channel.listen('onText_sendLLMMessage') satisfies Event)(e => { this.llmMessageHooks.onText[e.requestId]?.(e) })) + this._register((this.channel.listen('onFinalMessage_sendLLMMessage') satisfies Event)(e => { this.llmMessageHooks.onFinalMessage[e.requestId]?.(e); this._onRequestIdDone(e.requestId) })) + this._register((this.channel.listen('onError_sendLLMMessage') satisfies Event)(e => { this.llmMessageHooks.onError[e.requestId]?.(e); this._onRequestIdDone(e.requestId); console.error('Error in LLMMessageService:', JSON.stringify(e)) })) // ollama .list() - this._register((this.channel.listen('onSuccess_ollama') satisfies Event>)(e => { - this.onSuccess_ollama[e.requestId]?.(e) - })) - this._register((this.channel.listen('onError_ollama') satisfies Event>)(e => { - this.onError_ollama[e.requestId]?.(e) - })) - // openaiCompatible .list() - this._register((this.channel.listen('onSuccess_openAICompatible') satisfies Event>)(e => { - this.onSuccess_openAICompatible[e.requestId]?.(e) - })) - this._register((this.channel.listen('onError_openAICompatible') satisfies Event>)(e => { - this.onError_openAICompatible[e.requestId]?.(e) - })) + this._register((this.channel.listen('onSuccess_list_ollama') satisfies Event>)(e => { this.listHooks.ollama.success[e.requestId]?.(e) })) + this._register((this.channel.listen('onError_list_ollama') satisfies Event>)(e => { this.listHooks.ollama.error[e.requestId]?.(e) })) + this._register((this.channel.listen('onSuccess_list_vLLM') satisfies Event>)(e => { this.listHooks.vLLM.success[e.requestId]?.(e) })) + this._register((this.channel.listen('onError_list_vLLM') satisfies Event>)(e => { this.listHooks.vLLM.error[e.requestId]?.(e) })) } @@ -99,15 +93,15 @@ export class LLMMessageService extends Disposable implements ILLMMessageService let message: string if (isDisabled === 'addProvider' || isDisabled === 'providerNotAutoDetected') - message = `Please add a provider in Void Settings.` + message = `Please add a provider in Void's Settings.` else if (isDisabled === 'addModel') message = `Please add a model.` else if (isDisabled === 'needToEnableModel') message = `Please enable a model.` else if (isDisabled === 'notFilledIn') - message = `Please fill in Void Settings${modelSelection !== null ? ` for ${displayInfoOfProviderName(modelSelection.providerName).title}` : ''}.` + message = `Please fill in Void's Settings${modelSelection !== null ? ` for ${displayInfoOfProviderName(modelSelection.providerName).title}` : ''}.` else - message = 'Please add a provider in Void Settings.' + message = `Please add a provider in Void's Settings.` onError({ message, fullError: null }) return null @@ -117,9 +111,9 @@ export class LLMMessageService extends Disposable implements ILLMMessageService // add state for request id const requestId = generateUuid(); - this.onTextHooks_llm[requestId] = onText - this.onFinalMessageHooks_llm[requestId] = onFinalMessage - this.onErrorHooks_llm[requestId] = onError + this.llmMessageHooks.onText[requestId] = onText + this.llmMessageHooks.onFinalMessage[requestId] = onFinalMessage + this.llmMessageHooks.onError[requestId] = onError const { aiInstructions } = this.voidSettingsService.state.globalSettings const { settingsOfProvider } = this.voidSettingsService.state @@ -151,43 +145,46 @@ export class LLMMessageService extends Disposable implements ILLMMessageService // add state for request id const requestId_ = generateUuid(); - this.onSuccess_ollama[requestId_] = onSuccess - this.onError_ollama[requestId_] = onError + this.listHooks.ollama.success[requestId_] = onSuccess + this.listHooks.ollama.error[requestId_] = onError this.channel.call('ollamaList', { ...proxyParams, settingsOfProvider, + providerName: 'ollama', requestId: requestId_, } satisfies MainModelListParams) } - openAICompatibleList = (params: ServiceModelListParams) => { + vLLMList = (params: ServiceModelListParams) => { const { onSuccess, onError, ...proxyParams } = params const { settingsOfProvider } = this.voidSettingsService.state // add state for request id const requestId_ = generateUuid(); - this.onSuccess_openAICompatible[requestId_] = onSuccess - this.onError_openAICompatible[requestId_] = onError + this.listHooks.vLLM.success[requestId_] = onSuccess + this.listHooks.vLLM.error[requestId_] = onError - this.channel.call('openAICompatibleList', { + this.channel.call('vLLMList', { ...proxyParams, settingsOfProvider, + providerName: 'vLLM', requestId: requestId_, - } satisfies MainModelListParams) + } satisfies MainModelListParams) } - - _onRequestIdDone(requestId: string) { - delete this.onTextHooks_llm[requestId] - delete this.onFinalMessageHooks_llm[requestId] - delete this.onErrorHooks_llm[requestId] + delete this.llmMessageHooks.onText[requestId] + delete this.llmMessageHooks.onFinalMessage[requestId] + delete this.llmMessageHooks.onError[requestId] + + delete this.listHooks.ollama.success[requestId] + delete this.listHooks.ollama.error[requestId] - delete this.onSuccess_ollama[requestId] - delete this.onError_ollama[requestId] + delete this.listHooks.vLLM.success[requestId] + delete this.listHooks.vLLM.error[requestId] } } diff --git a/src/vs/workbench/contrib/void/common/llmMessageTypes.ts b/src/vs/workbench/contrib/void/common/llmMessageTypes.ts index 0956b08b5f0..abe88970e70 100644 --- a/src/vs/workbench/contrib/void/common/llmMessageTypes.ts +++ b/src/vs/workbench/contrib/void/common/llmMessageTypes.ts @@ -45,7 +45,7 @@ export type ToolCallType = { } -export type OnText = (p: { newText: string, fullText: string }) => void +export type OnText = (p: { newText: string, fullText: string; newReasoning: string; fullReasoning: string }) => void export type OnFinalMessage = (p: { fullText: string, toolCalls?: ToolCallType[] }) => void // id is tool_use_id export type OnError = (p: { message: string, fullError: Error | null }) => void export type AbortRef = { current: (() => void) | null } @@ -65,7 +65,7 @@ export const toLLMChatMessage = (c: ChatMessage): LLMChatMessage => { } -type _InternalSendFIMMessage = { +export type LLMFIMMessage = { prefix: string; suffix: string; stopTokens: string[]; @@ -77,7 +77,7 @@ type SendLLMType = { tools?: InternalToolInfo[]; } | { messagesType: 'FIMMessage'; - messages: _InternalSendFIMMessage; + messages: LLMFIMMessage; tools?: undefined; } @@ -118,38 +118,6 @@ export type EventLLMMessageOnFinalMessageParams = Parameters[0] export type EventLLMMessageOnErrorParams = Parameters[0] & { requestId: string } -export type _InternalSendLLMChatMessageFnType = ( - params: { - aiInstructions: string; - - onText: OnText; - onFinalMessage: OnFinalMessage; - onError: OnError; - providerName: ProviderName; - settingsOfProvider: SettingsOfProvider; - modelName: string; - _setAborter: (aborter: () => void) => void; - - tools?: InternalToolInfo[], - - messages: LLMChatMessage[]; - } -) => void - -export type _InternalSendLLMFIMMessageFnType = ( - params: { - onText: OnText; - onFinalMessage: OnFinalMessage; - onError: OnError; - providerName: ProviderName; - settingsOfProvider: SettingsOfProvider; - modelName: string; - _setAborter: (aborter: () => void) => void; - - messages: _InternalSendFIMMessage; - } -) => void - // service -> main -> internal -> event (back to main) // (browser) @@ -181,18 +149,22 @@ export type OllamaModelResponse = { size_vram: number; } -export type OpenaiCompatibleModelResponse = { +type OpenaiCompatibleModelResponse = { id: string; created: number; object: 'model'; owned_by: string; } +export type VLLMModelResponse = OpenaiCompatibleModelResponse + + // params to the true list fn -export type ModelListParams = { +export type ModelListParams = { + providerName: ProviderName; settingsOfProvider: SettingsOfProvider; - onSuccess: (param: { models: modelResponse[] }) => void; + onSuccess: (param: { models: ModelResponse[] }) => void; onError: (param: { error: string }) => void; } @@ -211,4 +183,3 @@ export type EventModelListOnErrorParams = Parameters = (params: ModelListParams) => void diff --git a/src/vs/workbench/contrib/void/common/refreshModelService.ts b/src/vs/workbench/contrib/void/common/refreshModelService.ts index 1c95a4ad261..1d68b304202 100644 --- a/src/vs/workbench/contrib/void/common/refreshModelService.ts +++ b/src/vs/workbench/contrib/void/common/refreshModelService.ts @@ -8,7 +8,7 @@ import { ILLMMessageService } from './llmMessageService.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; import { RefreshableProviderName, refreshableProviderNames, SettingsOfProvider } from './voidSettingsTypes.js'; -import { OllamaModelResponse, OpenaiCompatibleModelResponse } from './llmMessageTypes.js'; +import { OllamaModelResponse, VLLMModelResponse } from './llmMessageTypes.js'; import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; @@ -160,9 +160,8 @@ export class RefreshModelService extends Disposable implements IRefreshModelServ } } const listFn = providerName === 'ollama' ? this.llmMessageService.ollamaList - : providerName === 'vLLM' ? this.llmMessageService.openAICompatibleList - : providerName === 'openAICompatible' ? this.llmMessageService.openAICompatibleList - : () => { } + : providerName === 'vLLM' ? this.llmMessageService.vLLMList + : () => { } listFn({ onSuccess: ({ models }) => { @@ -172,8 +171,7 @@ export class RefreshModelService extends Disposable implements IRefreshModelServ providerName, models.map(model => { if (providerName === 'ollama') return (model as OllamaModelResponse).name; - else if (providerName === 'vLLM') return (model as OpenaiCompatibleModelResponse).id; - else if (providerName === 'openAICompatible') return (model as OpenaiCompatibleModelResponse).id; + else if (providerName === 'vLLM') return (model as VLLMModelResponse).id; else throw new Error('refreshMode fn: unknown provider', providerName); }), { enableProviderOnSuccess: options.enableProviderOnSuccess, hideRefresh: options.doNotFire } diff --git a/src/vs/workbench/contrib/void/common/toolsService.ts b/src/vs/workbench/contrib/void/common/toolsService.ts index 3b609f608ef..f27739c0644 100644 --- a/src/vs/workbench/contrib/void/common/toolsService.ts +++ b/src/vs/workbench/contrib/void/common/toolsService.ts @@ -1,13 +1,12 @@ import { CancellationToken } from '../../../../base/common/cancellation.js' import { URI } from '../../../../base/common/uri.js' -import { IModelService } from '../../../../editor/common/services/model.js' import { IFileService } from '../../../../platform/files/common/files.js' import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js' import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js' import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js' -import { VSReadFile } from '../../../../workbench/contrib/void/browser/helpers/readFile.js' import { QueryBuilder } from '../../../../workbench/services/search/common/queryBuilder.js' import { ISearchService } from '../../../../workbench/services/search/common/search.js' +import { IVoidFileService } from './voidFileService.js' // tool use for AI @@ -24,7 +23,6 @@ export type InternalToolInfo = { required: string[], // required paramNames } -// helper const paginationHelper = { desc: `Very large results may be paginated (indicated in the result). Pagination fails gracefully if out of bounds or invalid page number.`, param: { pageNumber: { type: 'number', description: 'The page number (optional, default is 1).' }, } @@ -33,7 +31,7 @@ const paginationHelper = { export const voidTools = { read_file: { name: 'read_file', - description: 'Returns file contents of a given URI.', + description: `Returns file contents of a given URI. ${paginationHelper.desc}`, params: { uri: { type: 'string', description: undefined }, }, @@ -57,7 +55,7 @@ export const voidTools = { query: { type: 'string', description: undefined }, ...paginationHelper.param, }, - required: ['query'] + required: ['query'], }, search: { @@ -70,6 +68,18 @@ export const voidTools = { required: ['query'], }, + // go_to_definition: + + // go_to_usages: + + // create_file: { + // name: 'create_file', + // description: `Creates a file at the given path. Fails gracefully if the file already exists by doing nothing.` + // params: { + // uri: { type: 'string', description: undefined }, + // } + // } + // semantic_search: { // description: 'Searches files semantically for the given string query.', // // RAG @@ -79,69 +89,103 @@ export const voidTools = { export type ToolName = keyof typeof voidTools export const toolNames = Object.keys(voidTools) as ToolName[] +const toolNamesSet = new Set(toolNames) +export const isAToolName = (toolName: string): toolName is ToolName => { + const isAToolName = toolNamesSet.has(toolName) + return isAToolName +} + + export type ToolParamNames = keyof typeof voidTools[T]['params'] export type ToolParamsObj = { [paramName in ToolParamNames]: unknown } +export type ToolCallReturnType = { + 'read_file': { uri: URI, fileContents: string, hasNextPage: boolean }, + 'list_dir': { rootURI: URI, children: DirectoryItem[] | null, hasNextPage: boolean, hasPrevPage: boolean, itemsRemaining: number }, + 'pathname_search': { queryStr: string, uris: URI[] | string, hasNextPage: boolean }, + 'search': { queryStr: string, uris: URI[] | string, hasNextPage: boolean } + 'create_file': {} +} -export type ToolCallReturnType - = T extends 'read_file' ? string - : T extends 'list_dir' ? string - : T extends 'pathname_search' ? string | URI[] - : T extends 'search' ? string | URI[] - : never +type DirectoryItem = { + name: string; + isDirectory: boolean; + isSymbolicLink: boolean; +} -export type ToolFns = { [T in ToolName]: (p: string) => Promise<[ToolCallReturnType, boolean]> } -export type ToolResultToString = { [T in ToolName]: (result: [ToolCallReturnType, boolean]) => string } +export type ToolFns = { [T in ToolName]: (p: string) => Promise } +export type ToolResultToString = { [T in ToolName]: (result: ToolCallReturnType[T]) => string } // pagination info const MAX_FILE_CHARS_PAGE = 50_000 const MAX_CHILDREN_URIs_PAGE = 500 -const MAX_DEPTH = 1 -async function generateDirectoryTreeMd(fileService: IFileService, rootURI: URI, pageNumber: number): Promise<[string, boolean]> { - let output = ''; - const indentation = (depth: number, isLast: boolean): string => { - if (depth === 0) return ''; - return `${'| '.repeat(depth - 1)}${isLast ? '└── ' : '├── '}`; - }; - let hasNextPage = false +const computeDirectoryResult = async ( + fileService: IFileService, + rootURI: URI, + pageNumber: number = 1 +): Promise => { + const stat = await fileService.resolve(rootURI, { resolveMetadata: false }); + if (!stat.isDirectory) { + return { rootURI, children: null, hasNextPage: false, hasPrevPage: false, itemsRemaining: 0 }; + } - async function traverseChildren(uri: URI, depth: number, isLast: boolean) { - const stat = await fileService.resolve(uri, { resolveMetadata: false }); + const originalChildrenLength = stat.children?.length ?? 0; + const fromChildIdx = MAX_CHILDREN_URIs_PAGE * (pageNumber - 1); + const toChildIdx = MAX_CHILDREN_URIs_PAGE * pageNumber - 1; // INCLUSIVE + const listChildren = stat.children?.slice(fromChildIdx, toChildIdx + 1) ?? []; + + const children: DirectoryItem[] = listChildren.map(child => ({ + name: child.name, + isDirectory: child.isDirectory, + isSymbolicLink: child.isSymbolicLink || false + })); + + const hasNextPage = (originalChildrenLength - 1) > toChildIdx; + const hasPrevPage = pageNumber > 1; + const itemsRemaining = Math.max(0, originalChildrenLength - (toChildIdx + 1)); + + return { + rootURI, + children, + hasNextPage, + hasPrevPage, + itemsRemaining + }; +}; - // we might want to say where symlink links to - if ((depth === 0 && pageNumber === 1) || depth !== 0) - output += `${indentation(depth, isLast)}${stat.name}${stat.isDirectory ? '/' : ''}${stat.isSymbolicLink ? ` (symbolic link)` : ''}\n`; +const directoryResultToString = (result: ToolCallReturnType['list_dir']): string => { + if (!result.children) { + return `Error: ${result.rootURI} is not a directory`; + } - // list children - const originalChildrenLength = stat.children?.length ?? 0 - const fromChildIdx = MAX_CHILDREN_URIs_PAGE * (pageNumber - 1) - const toChildIdx = MAX_CHILDREN_URIs_PAGE * pageNumber - 1 // INCLUSIVE - const listChildren = stat.children?.slice(fromChildIdx, toChildIdx + 1) ?? []; + let output = ''; + const entries = result.children; - if (!stat.isDirectory) return; + if (!result.hasPrevPage) { + output += `${result.rootURI}\n`; + } - if (listChildren.length === 0) return - if (depth === MAX_DEPTH) return // right now MAX_DEPTH=1 to make pagination work nicely + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + const isLast = i === entries.length - 1 && !result.hasNextPage; + const prefix = isLast ? '└── ' : '├── '; - for (let i = 0; i < Math.min(listChildren.length, MAX_CHILDREN_URIs_PAGE); i++) { - await traverseChildren(listChildren[i].resource, depth + 1, i === listChildren.length - 1); - } - const nCutoffResults = (originalChildrenLength - 1) - toChildIdx - if (nCutoffResults >= 1) { - output += `${indentation(depth + 1, true)}(${nCutoffResults} results remaining...)\n` - hasNextPage = true - } + output += `${prefix}${entry.name}${entry.isDirectory ? '/' : ''}${entry.isSymbolicLink ? ' (symbolic link)' : ''}\n`; + } + if (result.hasNextPage) { + output += `└── (${result.itemsRemaining} results remaining...)\n`; } - await traverseChildren(rootURI, 0, false); + return output; +}; + + - return [output, hasNextPage] -} const validateJSON = (s: string): { [s: string]: unknown } => { @@ -162,8 +206,10 @@ const validateQueryStr = (queryStr: unknown) => { } +// TODO!!!! check to make sure in workspace const validateURI = (uriStr: unknown) => { if (typeof uriStr !== 'string') throw new Error('Error calling tool: provided uri must be a string.') + const uri = URI.file(uriStr) return uri } @@ -192,43 +238,52 @@ export class ToolsService implements IToolsService { constructor( @IFileService fileService: IFileService, - @IModelService modelService: IModelService, @IWorkspaceContextService workspaceContextService: IWorkspaceContextService, @ISearchService searchService: ISearchService, @IInstantiationService instantiationService: IInstantiationService, + @IVoidFileService voidFileService: IVoidFileService, ) { const queryBuilder = instantiationService.createInstance(QueryBuilder); this.toolFns = { read_file: async (s: string) => { + console.log('read_file') + const o = validateJSON(s) const { uri: uriStr, pageNumber: pageNumberUnknown } = o const uri = validateURI(uriStr) const pageNumber = validatePageNum(pageNumberUnknown) - const readFileContents = await VSReadFile(uri, modelService, fileService) + const readFileContents = await voidFileService.readFile(uri) const fromIdx = MAX_FILE_CHARS_PAGE * (pageNumber - 1) const toIdx = MAX_FILE_CHARS_PAGE * pageNumber - 1 - let fileContents = readFileContents.slice(fromIdx, toIdx + 1) // paginate + const fileContents = readFileContents.slice(fromIdx, toIdx + 1) || '(empty)' // paginate const hasNextPage = (readFileContents.length - 1) - toIdx >= 1 - return [fileContents || '(empty)', hasNextPage] + + console.log('read_file result:', fileContents) + + + return { uri, fileContents, hasNextPage } }, list_dir: async (s: string) => { + console.log('list_dir') const o = validateJSON(s) const { uri: uriStr, pageNumber: pageNumberUnknown } = o const uri = validateURI(uriStr) const pageNumber = validatePageNum(pageNumberUnknown) - // TODO!!!! check to make sure in workspace - const [treeStr, hasNextPage] = await generateDirectoryTreeMd(fileService, uri, pageNumber) - return [treeStr, hasNextPage] + const dirResult = await computeDirectoryResult(fileService, uri, pageNumber) + console.log('list_dir result:', dirResult) + + return dirResult }, pathname_search: async (s: string) => { + console.log('pathname_search') const o = validateJSON(s) const { query: queryUnknown, pageNumber: pageNumberUnknown } = o @@ -240,15 +295,20 @@ export class ToolsService implements IToolsService { const fromIdx = MAX_CHILDREN_URIs_PAGE * (pageNumber - 1) const toIdx = MAX_CHILDREN_URIs_PAGE * pageNumber - 1 - const URIs = data.results + const uris = data.results .slice(fromIdx, toIdx + 1) // paginate .map(({ resource, results }) => resource) const hasNextPage = (data.results.length - 1) - toIdx >= 1 + console.log('pathname_search result:', uris) - return [URIs, hasNextPage] + return { queryStr, uris, hasNextPage } }, search: async (s: string) => { + + + console.log('search') + const o = validateJSON(s) const { query: queryUnknown, pageNumber: pageNumberUnknown } = o @@ -260,34 +320,37 @@ export class ToolsService implements IToolsService { const fromIdx = MAX_CHILDREN_URIs_PAGE * (pageNumber - 1) const toIdx = MAX_CHILDREN_URIs_PAGE * pageNumber - 1 - const URIs = data.results + const uris = data.results .slice(fromIdx, toIdx + 1) // paginate .map(({ resource, results }) => resource) const hasNextPage = (data.results.length - 1) - toIdx >= 1 - return [URIs, hasNextPage] + console.log('search result:', uris) + + return { queryStr, uris, hasNextPage } }, - } + } const nextPageStr = (hasNextPage: boolean) => hasNextPage ? '\n\n(more on next page...)' : '' this.toolResultToString = { - read_file: ([fileContents, hasNextPage]) => { - return fileContents + nextPageStr(hasNextPage) + read_file: (result) => { + return nextPageStr(result.hasNextPage) }, - list_dir: ([dirTreeStr, hasNextPage]) => { - return dirTreeStr + nextPageStr(hasNextPage) + list_dir: (result) => { + const dirTreeStr = directoryResultToString(result) + return dirTreeStr + nextPageStr(result.hasNextPage) }, - pathname_search: ([URIs, hasNextPage]) => { - if (typeof URIs === 'string') return URIs - return URIs.map(uri => uri.fsPath).join('\n') + nextPageStr(hasNextPage) + pathname_search: (result) => { + if (typeof result.uris === 'string') return result.uris + return result.uris.map(uri => uri.fsPath).join('\n') + nextPageStr(result.hasNextPage) }, - search: ([URIs, hasNextPage]) => { - if (typeof URIs === 'string') return URIs - return URIs.map(uri => uri.fsPath).join('\n') + nextPageStr(hasNextPage) + search: (result) => { + if (typeof result.uris === 'string') return result.uris + return result.uris.map(uri => uri.fsPath).join('\n') + nextPageStr(result.hasNextPage) }, } @@ -299,4 +362,3 @@ export class ToolsService implements IToolsService { } registerSingleton(IToolsService, ToolsService, InstantiationType.Eager); - diff --git a/src/vs/workbench/contrib/void/common/voidFileService.ts b/src/vs/workbench/contrib/void/common/voidFileService.ts new file mode 100644 index 00000000000..a7c2563179b --- /dev/null +++ b/src/vs/workbench/contrib/void/common/voidFileService.ts @@ -0,0 +1,109 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { isWindows } from '../../../../base/common/platform.js'; +import { URI } from '../../../../base/common/uri.js'; +import { EndOfLinePreference } from '../../../../editor/common/model.js'; +import { IModelService } from '../../../../editor/common/services/model.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; + + +// linebreak symbols +export const allLinebreakSymbols = ['\r\n', '\n'] +export const _ln = isWindows ? allLinebreakSymbols[0] : allLinebreakSymbols[1] + +export interface IVoidFileService { + readonly _serviceBrand: undefined; + + readFile(uri: URI, range?: { startLineNumber: number, endLineNumber: number }): Promise; + readModel(uri: URI, range?: { startLineNumber: number, endLineNumber: number }): string | null; +} + +export const IVoidFileService = createDecorator('VoidFileService'); + +// implemented by calling channel +export class VoidFileService implements IVoidFileService { + readonly _serviceBrand: undefined; + + constructor( + @IModelService private readonly modelService: IModelService, + @IFileService private readonly fileService: IFileService, + ) { + + } + + readFile = async (uri: URI, range?: { startLineNumber: number, endLineNumber: number }): Promise => { + + // attempt to read the model + const modelResult = this.readModel(uri, range); + if (modelResult) return modelResult; + + // if no model, read the raw file + const fileResult = await this._readFileRaw(uri, range); + if (fileResult) return fileResult; + + return ''; + } + + _readFileRaw = async (uri: URI, range?: { startLineNumber: number, endLineNumber: number }): Promise => { + + try { // this throws an error if no file exists (eg it was deleted) + + const res = await this.fileService.readFile(uri); + + if (range) { + return res.value.toString() + .split(_ln) + .slice(range.startLineNumber - 1, range.endLineNumber) + .join(_ln) + } + + return res.value.toString(); + + + } catch (e) { + return null; + } + } + + + readModel = (uri: URI, range?: { startLineNumber: number, endLineNumber: number }): string | null => { + + // read saved model (sometimes null if the user reloads application) + let model = this.modelService.getModel(uri); + + // check all opened models for the same `fsPath` + if (!model) { + const models = this.modelService.getModels(); + for (const m of models) { + if (m.uri.fsPath === uri.fsPath) { + model = m + break; + } + } + } + + // if still not found, return + if (!model) { return null } + + // if range, read it + if (range) { + return model.getValueInRange({ + startLineNumber: range.startLineNumber, + endLineNumber: range.endLineNumber, + startColumn: 1, + endColumn: Number.MAX_VALUE + }, EndOfLinePreference.LF); + } else { + return model.getValue(EndOfLinePreference.LF) + } + + } + +} + +registerSingleton(IVoidFileService, VoidFileService, InstantiationType.Eager); diff --git a/src/vs/workbench/contrib/void/common/voidSettingsService.ts b/src/vs/workbench/contrib/void/common/voidSettingsService.ts index 7a35c6786f4..72095c83fd2 100644 --- a/src/vs/workbench/contrib/void/common/voidSettingsService.ts +++ b/src/vs/workbench/contrib/void/common/voidSettingsService.ts @@ -11,7 +11,7 @@ import { registerSingleton, InstantiationType } from '../../../../platform/insta import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IMetricsService } from './metricsService.js'; -import { defaultSettingsOfProvider, FeatureName, ProviderName, ModelSelectionOfFeature, SettingsOfProvider, SettingName, providerNames, ModelSelection, modelSelectionsEqual, featureNames, VoidModelInfo, GlobalSettings, GlobalSettingName, defaultGlobalSettings, defaultProviderSettings, developerInfoOfModelName, modelInfoOfAutodetectedModelNames } from './voidSettingsTypes.js'; +import { defaultSettingsOfProvider, FeatureName, ProviderName, ModelSelectionOfFeature, SettingsOfProvider, SettingName, providerNames, ModelSelection, modelSelectionsEqual, featureNames, VoidModelInfo, GlobalSettings, GlobalSettingName, defaultGlobalSettings, defaultProviderSettings } from './voidSettingsTypes.js'; const STORAGE_KEY = 'void.settingsServiceStorage' @@ -32,8 +32,6 @@ type SetGlobalSettingFn = (settingName: T, newVal export type ModelOption = { name: string, selection: ModelSelection } - - export type VoidSettingsState = { readonly settingsOfProvider: SettingsOfProvider; // optionsOfProvider readonly modelSelectionOfFeature: ModelSelectionOfFeature; // stateOfFeature @@ -65,7 +63,30 @@ export interface IVoidSettingsService { -const _updatedValidatedState = (state: Omit) => { + +const _updatedModelsAfterDefaultModelsChange = (defaultModelNames: string[], options: { existingModels: VoidModelInfo[] }) => { + const { existingModels } = options + + const existingModelsMap: Record = {} + for (const existingModel of existingModels) { + existingModelsMap[existingModel.modelName] = existingModel + } + + const newDefaultModels = defaultModelNames.map((modelName, i) => ({ + modelName, + isDefault: true, + isAutodetected: true, + isHidden: !!existingModelsMap[modelName]?.isHidden, + })) + + return [ + ...newDefaultModels, // swap out all the default models for the new default models + ...existingModels.filter(m => !m.isDefault), // keep any non-default (custom) models + ] +} + + +const _validatedState = (state: Omit) => { let newSettingsOfProvider = state.settingsOfProvider @@ -172,9 +193,6 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { // A HACK BECAUSE WE ADDED DEEPSEEK (did not exist before, comes before readS) ...{ deepseek: defaultSettingsOfProvider.deepseek }, - // A HACK BECAUSE WE ADDED MISTRAL (did not exist before, comes before readS) - ...{ mistral: defaultSettingsOfProvider.mistral }, - // A HACK BECAUSE WE ADDED XAI (did not exist before, comes before readS) ...{ xAI: defaultSettingsOfProvider.xAI }, @@ -206,7 +224,7 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { modelSelectionOfFeature: newModelSelectionOfFeature, } - this.state = _updatedValidatedState(readS) + this.state = _validatedState(readS) resolver() this._onDidChangeState.fire() @@ -253,7 +271,7 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { globalSettings: newGlobalSettings, } - this.state = _updatedValidatedState(newState) + this.state = _validatedState(newState) await this._storeState() this._onDidChangeState.fire() @@ -296,18 +314,13 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { + setAutodetectedModels(providerName: ProviderName, autodetectedModelNames: string[], logging: object) { const { models } = this.state.settingsOfProvider[providerName] const oldModelNames = models.map(m => m.modelName) - - const newDefaultModels = modelInfoOfAutodetectedModelNames(autodetectedModelNames, { existingModels: models }) - const newModels = [ - ...newDefaultModels, // swap out all the default models for the new default models - ...models.filter(m => !m.isDefault), // keep any non-default (custom) models - ] - + const newModels = _updatedModelsAfterDefaultModelsChange(autodetectedModelNames, { existingModels: models }) this.setSettingOfProvider(providerName, 'models', newModels) // if the models changed, log it @@ -341,7 +354,7 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { if (existingIdx !== -1) return // if exists, do nothing const newModels = [ ...models, - { ...developerInfoOfModelName(modelName), modelName, isDefault: false, isHidden: false } + { modelName, isDefault: false, isHidden: false } ] this.setSettingOfProvider(providerName, 'models', newModels) diff --git a/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts b/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts index 06e708fd184..379a48176fb 100644 --- a/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts +++ b/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts @@ -7,364 +7,9 @@ import { VoidSettingsState } from './voidSettingsService.js' - -// developer info used in sendLLMMessage -export type DeveloperInfoAtModel = { - // USED: - supportsSystemMessage: 'developer' | boolean, // if null, we will just do a string of system message. this is independent from separateSystemMessage, which takes priority and is passed directly in each provider's implementation. - supportsTools: boolean, // we will just do a string of tool use if it doesn't support - - // UNUSED (coming soon): - // TODO!!! think tokens - deepseek - _recognizedModelName: RecognizedModelName, // used to show user if model was auto-recognized - _supportsStreaming: boolean, // we will just dump the final result if doesn't support it - _supportsAutocompleteFIM: boolean, // we will just do a description of FIM if it doens't support <|fim_hole|> - _maxTokens: number, // required -} - -export type DeveloperInfoAtProvider = { - overrideSettingsForAllModels?: Partial; // any overrides for models that a provider might have (e.g. if a provider always supports tool use, even if we don't recognize the model we can set tools to true) -} - - - - - -export type VoidModelInfo = { // <-- STATEFUL - modelName: string, - isDefault: boolean, // whether or not it's a default for its provider - isHidden: boolean, // whether or not the user is hiding it (switched off) - isAutodetected?: boolean, // whether the model was autodetected by polling -} & DeveloperInfoAtModel - - - - - -export const recognizedModels = [ - // chat - 'OpenAI 4o', - 'Anthropic Claude', - 'Llama 3.x', - 'Deepseek Chat', // deepseek coder v2 is now merged into chat (V3) https://api-docs.deepseek.com/updates#deepseek-coder--deepseek-chat-upgraded-to-deepseek-v25-model - 'xAI Grok', - // 'xAI Grok', - // 'Google Gemini, Gemma', - // 'Microsoft Phi4', - - - // coding (autocomplete) - 'Alibaba Qwen2.5 Coder Instruct', // we recommend this over Qwen2.5 - 'Mistral Codestral', - - // thinking - 'OpenAI o1', - 'Deepseek R1', - - // general - // 'Mixtral 8x7b' - // 'Qwen2.5', - -] as const - -type RecognizedModelName = (typeof recognizedModels)[number] | '' - - -export function recognizedModelOfModelName(modelName: string): RecognizedModelName { - const lower = modelName.toLowerCase(); - - if (lower.includes('gpt-4o')) - return 'OpenAI 4o'; - if (lower.includes('claude')) - return 'Anthropic Claude'; - if (lower.includes('llama')) - return 'Llama 3.x'; - if (lower.includes('qwen2.5-coder')) - return 'Alibaba Qwen2.5 Coder Instruct'; - if (lower.includes('mistral')) - return 'Mistral Codestral'; - if (/\bo1\b/.test(lower) || /\bo3\b/.test(lower)) // o1, o3 - return 'OpenAI o1'; - if (lower.includes('deepseek-r1') || lower.includes('deepseek-reasoner')) - return 'Deepseek R1'; - if (lower.includes('deepseek')) - return 'Deepseek Chat' - if (lower.includes('grok')) - return 'xAI Grok' - - return ''; -} - - -const developerInfoAtProvider: { [providerName in ProviderName]: DeveloperInfoAtProvider } = { - 'anthropic': { - overrideSettingsForAllModels: { - supportsSystemMessage: true, - supportsTools: true, - _supportsAutocompleteFIM: false, - _supportsStreaming: true, - } - }, - 'deepseek': { - overrideSettingsForAllModels: { - } - }, - 'ollama': { - }, - 'openRouter': { - }, - 'openAICompatible': { - }, - 'openAI': { - }, - 'gemini': { - }, - 'mistral': { - }, - 'groq': { - }, - 'xAI': { - }, - 'vLLM': { - }, -} -export const developerInfoOfProviderName = (providerName: ProviderName): Partial => { - return developerInfoAtProvider[providerName] ?? {} -} - - - - -// providerName is optional, but gives some extra fallbacks if provided -const developerInfoOfRecognizedModelName: { [recognizedModel in RecognizedModelName]: Omit } = { - 'OpenAI 4o': { - supportsSystemMessage: true, - supportsTools: true, - _supportsAutocompleteFIM: false, - _supportsStreaming: true, - _maxTokens: 4096, - }, - - 'Anthropic Claude': { - supportsSystemMessage: true, - supportsTools: false, - _supportsAutocompleteFIM: false, - _supportsStreaming: false, - _maxTokens: 4096, - }, - - 'Llama 3.x': { - supportsSystemMessage: true, - supportsTools: true, - _supportsAutocompleteFIM: false, - _supportsStreaming: false, - _maxTokens: 4096, - }, - - 'xAI Grok': { - supportsSystemMessage: true, - supportsTools: true, - _supportsAutocompleteFIM: false, - _supportsStreaming: true, - _maxTokens: 4096, - - }, - - 'Deepseek Chat': { - supportsSystemMessage: true, - supportsTools: false, - _supportsAutocompleteFIM: false, - _supportsStreaming: false, - _maxTokens: 4096, - }, - - 'Alibaba Qwen2.5 Coder Instruct': { - supportsSystemMessage: true, - supportsTools: true, - _supportsAutocompleteFIM: false, - _supportsStreaming: false, - _maxTokens: 4096, - }, - - 'Mistral Codestral': { - supportsSystemMessage: true, - supportsTools: true, - _supportsAutocompleteFIM: false, - _supportsStreaming: false, - _maxTokens: 4096, - }, - - 'OpenAI o1': { - supportsSystemMessage: 'developer', - supportsTools: false, - _supportsAutocompleteFIM: false, - _supportsStreaming: true, - _maxTokens: 4096, - }, - - 'Deepseek R1': { - supportsSystemMessage: false, - supportsTools: false, - _supportsAutocompleteFIM: false, - _supportsStreaming: false, - _maxTokens: 4096, - }, - - - '': { - supportsSystemMessage: false, - supportsTools: false, - _supportsAutocompleteFIM: false, - _supportsStreaming: false, - _maxTokens: 4096, - }, -} -export const developerInfoOfModelName = (modelName: string, overrides?: Partial): DeveloperInfoAtModel => { - const recognizedModelName = recognizedModelOfModelName(modelName) - return { - _recognizedModelName: recognizedModelName, - ...developerInfoOfRecognizedModelName[recognizedModelName], - ...overrides - } -} - - - - - - -// creates `modelInfo` from `modelNames` -export const modelInfoOfDefaultModelNames = (defaultModelNames: string[]): VoidModelInfo[] => { - return defaultModelNames.map((modelName, i) => ({ - modelName, - isDefault: true, - isAutodetected: false, - isHidden: defaultModelNames.length >= 10, // hide all models if there are a ton of them, and make user enable them individually - ...developerInfoOfModelName(modelName), - })) -} - -export const modelInfoOfAutodetectedModelNames = (defaultModelNames: string[], options: { existingModels: VoidModelInfo[] }) => { - const { existingModels } = options - - const existingModelsMap: Record = {} - for (const existingModel of existingModels) { - existingModelsMap[existingModel.modelName] = existingModel - } - - return defaultModelNames.map((modelName, i) => ({ - modelName, - isDefault: true, - isAutodetected: true, - isHidden: !!existingModelsMap[modelName]?.isHidden, - ...developerInfoOfModelName(modelName) - })) -} - - - - - -// https://docs.anthropic.com/en/docs/about-claude/models -export const defaultAnthropicModels = modelInfoOfDefaultModelNames([ - 'claude-3-5-sonnet-20241022', - 'claude-3-5-haiku-20241022', - 'claude-3-opus-20240229', - 'claude-3-sonnet-20240229', - // 'claude-3-haiku-20240307', -]) - - -// https://platform.openai.com/docs/models/gp -export const defaultOpenAIModels = modelInfoOfDefaultModelNames([ - 'o1', - 'o1-mini', - 'o3-mini', - 'gpt-4o', - 'gpt-4o-mini', - // 'gpt-4o-2024-05-13', - // 'gpt-4o-2024-08-06', - // 'gpt-4o-mini-2024-07-18', - // 'gpt-4-turbo', - // 'gpt-4-turbo-2024-04-09', - // 'gpt-4-turbo-preview', - // 'gpt-4-0125-preview', - // 'gpt-4-1106-preview', - // 'gpt-4', - // 'gpt-4-0613', - // 'gpt-3.5-turbo-0125', - // 'gpt-3.5-turbo', - // 'gpt-3.5-turbo-1106', -]) - -// https://platform.openai.com/docs/models/gp -export const defaultDeepseekModels = modelInfoOfDefaultModelNames([ - 'deepseek-chat', - 'deepseek-reasoner', -]) - - -// https://console.groq.com/docs/models -export const defaultGroqModels = modelInfoOfDefaultModelNames([ - "llama3-70b-8192", - "llama-3.3-70b-versatile", - "llama-3.1-8b-instant", - "gemma2-9b-it", - "mixtral-8x7b-32768" -]) - - -export const defaultGeminiModels = modelInfoOfDefaultModelNames([ - 'gemini-1.5-flash', - 'gemini-1.5-pro', - 'gemini-1.5-flash-8b', - 'gemini-2.0-flash-exp', - 'gemini-2.0-flash-thinking-exp-1219', - 'learnlm-1.5-pro-experimental' -]) - -export const defaultMistralModels = modelInfoOfDefaultModelNames([ - "codestral-latest", - "open-codestral-mamba", - "open-mistral-nemo", - "mistral-large-latest", - "pixtral-large-latest", - "ministral-3b-latest", - "ministral-8b-latest", - "mistral-small-latest", -]) - -export const defaultXAIModels = modelInfoOfDefaultModelNames([ - 'grok-2-latest', - 'grok-3-latest', -]) -// export const parseMaxTokensStr = (maxTokensStr: string) => { -// // parse the string but only if the full string is a valid number, eg parseInt('100abc') should return NaN -// const int = isNaN(Number(maxTokensStr)) ? undefined : parseInt(maxTokensStr) -// if (Number.isNaN(int)) -// return undefined -// return int -// } - - - - -export const anthropicMaxPossibleTokens = (modelName: string) => { - if (modelName === 'claude-3-5-sonnet-20241022' - || modelName === 'claude-3-5-haiku-20241022') - return 8192 - if (modelName === 'claude-3-opus-20240229' - || modelName === 'claude-3-sonnet-20240229' - || modelName === 'claude-3-haiku-20240307') - return 4096 - return 1024 // return a reasonably small number if they're using a different model -} - - type UnionOfKeys = T extends T ? keyof T : never; - export const defaultProviderSettings = { anthropic: { apiKey: '', @@ -394,14 +39,70 @@ export const defaultProviderSettings = { groq: { apiKey: '', }, - mistral: { - apiKey: '' - }, xAI: { apiKey: '' }, } as const + + + +export const defaultModelsOfProvider = { + openAI: [ // https://platform.openai.com/docs/models/gp + 'o1', + 'o3-mini', + 'o1-mini', + 'gpt-4o', + 'gpt-4o-mini', + ], + anthropic: [ // https://docs.anthropic.com/en/docs/about-claude/models + 'claude-3-5-sonnet-latest', + 'claude-3-5-haiku-latest', + 'claude-3-opus-latest', + ], + xAI: [ // https://docs.x.ai/docs/models?cluster=us-east-1 + 'grok-2-latest', + 'grok-3-latest', + ], + gemini: [ // https://ai.google.dev/gemini-api/docs/models/gemini + 'gemini-2.0-flash', + 'gemini-1.5-flash', + 'gemini-1.5-pro', + 'gemini-1.5-flash-8b', + 'gemini-2.0-flash-thinking-exp', + ], + deepseek: [ // https://api-docs.deepseek.com/quick_start/pricing + 'deepseek-chat', + 'deepseek-reasoner', + ], + ollama: [ // autodetected + ], + vLLM: [ // autodetected + ], + openRouter: [ // https://openrouter.ai/models + 'anthropic/claude-3.5-sonnet', + 'deepseek/deepseek-r1', + 'mistralai/codestral-2501', + 'qwen/qwen-2.5-coder-32b-instruct', + ], + groq: [ // https://console.groq.com/docs/models + 'llama-3.3-70b-versatile', + 'llama-3.1-8b-instant', + 'qwen-2.5-coder-32b', // preview mode (experimental) + ], + // not supporting mistral right now- it's last on Void usage, and a huge pain to set up since it's nonstandard (it supports codestral FIM but it's on v1/fim/completions, etc) + // mistral: [ // https://docs.mistral.ai/getting-started/models/models_overview/ + // 'codestral-latest', + // 'mistral-large-latest', + // 'ministral-3b-latest', + // 'ministral-8b-latest', + // ], + openAICompatible: [], // fallback +} as const satisfies Record + + + + export type ProviderName = keyof typeof defaultProviderSettings export const providerNames = Object.keys(defaultProviderSettings) as ProviderName[] @@ -418,6 +119,14 @@ export const customSettingNamesOfProvider = (providerName: ProviderName) => { +export type VoidModelInfo = { // <-- STATEFUL + modelName: string, + isDefault: boolean, // whether or not it's a default for its provider + isHidden: boolean, // whether or not the user is hiding it (switched off) + isAutodetected?: boolean, // whether the model was autodetected by polling +} // TODO!!! eventually we'd want to let the user change supportsFIM, etc on the model themselves + + type CommonProviderSettings = { _didFillInProviderSettings: boolean | undefined, // undefined initially, computed when user types in all fields @@ -434,10 +143,6 @@ export type SettingsOfProvider = { export type SettingName = keyof SettingsAtProvider - - - - type DisplayInfoForProviderName = { title: string, desc?: string, @@ -489,11 +194,6 @@ export const displayInfoOfProviderName = (providerName: ProviderName): DisplayIn title: 'Groq.com API', } } - else if (providerName === 'mistral') { - return { - title: 'Mistral API', - } - } else if (providerName === 'xAI') { return { title: 'xAI API', @@ -505,9 +205,10 @@ export const displayInfoOfProviderName = (providerName: ProviderName): DisplayIn } type DisplayInfo = { - title: string, - placeholder: string, - subTextMd?: string, + title: string; + placeholder: string; + subTextMd?: string; + isPasswordField?: boolean; } export const displayInfoOfSettingName = (providerName: ProviderName, settingName: SettingName): DisplayInfo => { if (settingName === 'apiKey') { @@ -522,10 +223,9 @@ export const displayInfoOfSettingName = (providerName: ProviderName, settingName providerName === 'openRouter' ? 'sk-or-key...' : // sk-or-v1-key providerName === 'gemini' ? 'key...' : providerName === 'groq' ? 'gsk_key...' : - providerName === 'mistral' ? 'key...' : - providerName === 'openAICompatible' ? 'sk-key...' : - providerName === 'xAI' ? 'xai-key...' : - '', + providerName === 'openAICompatible' ? 'sk-key...' : + providerName === 'xAI' ? 'xai-key...' : + '', subTextMd: providerName === 'anthropic' ? 'Get your [API Key here](https://console.anthropic.com/settings/keys).' : providerName === 'openAI' ? 'Get your [API Key here](https://platform.openai.com/api-keys).' : @@ -533,17 +233,17 @@ export const displayInfoOfSettingName = (providerName: ProviderName, settingName providerName === 'openRouter' ? 'Get your [API Key here](https://openrouter.ai/settings/keys).' : providerName === 'gemini' ? 'Get your [API Key here](https://aistudio.google.com/apikey).' : providerName === 'groq' ? 'Get your [API Key here](https://console.groq.com/keys).' : - providerName === 'mistral' ? 'Get your [API Key here](https://console.mistral.ai/api-keys/).' : - providerName === 'xAI' ? 'Get your [API Key here](https://console.x.ai).' : - providerName === 'openAICompatible' ? undefined : - '', + providerName === 'xAI' ? 'Get your [API Key here](https://console.x.ai).' : + providerName === 'openAICompatible' ? undefined : + '', + isPasswordField: true, } } else if (settingName === 'endpoint') { return { title: providerName === 'ollama' ? 'Endpoint' : providerName === 'vLLM' ? 'Endpoint' : - providerName === 'openAICompatible' ? 'baseURL' :// (do not include /chat/completions) + providerName === 'openAICompatible' ? 'baseURL' : // (do not include /chat/completions) '(never)', placeholder: providerName === 'ollama' ? defaultProviderSettings.ollama.endpoint @@ -582,110 +282,77 @@ const defaultCustomSettings: Record = { } - -export const voidInitModelOptions = { - anthropic: { - models: defaultAnthropicModels, - }, - openAI: { - models: defaultOpenAIModels, - }, - deepseek: { - models: defaultDeepseekModels, - }, - ollama: { - models: [], - }, - vLLM: { - models: [], - }, - openRouter: { - models: [], // any string - }, - openAICompatible: { - models: [], - }, - gemini: { - models: defaultGeminiModels, - }, - groq: { - models: defaultGroqModels, - }, - mistral: { - models: defaultMistralModels, - }, - xAI: { - models: defaultXAIModels, +const modelInfoOfDefaultModelNames = (defaultModelNames: string[]): { models: VoidModelInfo[] } => { + return { + models: defaultModelNames.map((modelName, i) => ({ + modelName, + isDefault: true, + isAutodetected: false, + isHidden: defaultModelNames.length >= 10, // hide all models if there are a ton of them, and make user enable them individually + })) } -} satisfies Record - +} // used when waiting and for a type reference export const defaultSettingsOfProvider: SettingsOfProvider = { anthropic: { ...defaultCustomSettings, ...defaultProviderSettings.anthropic, - ...voidInitModelOptions.anthropic, + ...modelInfoOfDefaultModelNames(defaultModelsOfProvider.anthropic), _didFillInProviderSettings: undefined, }, openAI: { ...defaultCustomSettings, ...defaultProviderSettings.openAI, - ...voidInitModelOptions.openAI, + ...modelInfoOfDefaultModelNames(defaultModelsOfProvider.openAI), _didFillInProviderSettings: undefined, }, deepseek: { ...defaultCustomSettings, ...defaultProviderSettings.deepseek, - ...voidInitModelOptions.deepseek, + ...modelInfoOfDefaultModelNames(defaultModelsOfProvider.deepseek), _didFillInProviderSettings: undefined, }, gemini: { ...defaultCustomSettings, ...defaultProviderSettings.gemini, - ...voidInitModelOptions.gemini, - _didFillInProviderSettings: undefined, - }, - mistral: { - ...defaultCustomSettings, - ...defaultProviderSettings.mistral, - ...voidInitModelOptions.mistral, + ...modelInfoOfDefaultModelNames(defaultModelsOfProvider.gemini), _didFillInProviderSettings: undefined, }, xAI: { ...defaultCustomSettings, ...defaultProviderSettings.xAI, - ...voidInitModelOptions.xAI, + ...modelInfoOfDefaultModelNames(defaultModelsOfProvider.xAI), _didFillInProviderSettings: undefined, }, groq: { // aggregator ...defaultCustomSettings, ...defaultProviderSettings.groq, - ...voidInitModelOptions.groq, + ...modelInfoOfDefaultModelNames(defaultModelsOfProvider.groq), _didFillInProviderSettings: undefined, }, openRouter: { // aggregator ...defaultCustomSettings, ...defaultProviderSettings.openRouter, - ...voidInitModelOptions.openRouter, + ...modelInfoOfDefaultModelNames(defaultModelsOfProvider.openRouter), _didFillInProviderSettings: undefined, }, openAICompatible: { // aggregator ...defaultCustomSettings, ...defaultProviderSettings.openAICompatible, - ...voidInitModelOptions.openAICompatible, + ...modelInfoOfDefaultModelNames(defaultModelsOfProvider.openAICompatible), _didFillInProviderSettings: undefined, }, ollama: { // aggregator ...defaultCustomSettings, ...defaultProviderSettings.ollama, - ...voidInitModelOptions.ollama, + ...modelInfoOfDefaultModelNames(defaultModelsOfProvider.ollama), _didFillInProviderSettings: undefined, }, vLLM: { // aggregator ...defaultCustomSettings, ...defaultProviderSettings.vLLM, - ...voidInitModelOptions.vLLM, + ...modelInfoOfDefaultModelNames(defaultModelsOfProvider.vLLM), _didFillInProviderSettings: undefined, }, } diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/MODELS.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/MODELS.ts new file mode 100644 index 00000000000..a4ad5487f30 --- /dev/null +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/MODELS.ts @@ -0,0 +1,1000 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import OpenAI, { ClientOptions } from 'openai'; +import Anthropic from '@anthropic-ai/sdk'; +import { Ollama } from 'ollama'; + +import { Model as OpenAIModel } from 'openai/resources/models.js'; +import { OllamaModelResponse, OnText, OnFinalMessage, OnError, LLMChatMessage, LLMFIMMessage, ModelListParams } from '../../common/llmMessageTypes.js'; +import { InternalToolInfo, isAToolName } from '../../common/toolsService.js'; +import { defaultProviderSettings, displayInfoOfProviderName, ProviderName, SettingsOfProvider } from '../../common/voidSettingsTypes.js'; +import { prepareFIMMessage, prepareMessages } from './preprocessLLMMessages.js'; +import { extractReasoningFromText } from '../../browser/helpers/extractCodeFromResult.js'; + + + +type ModelOptions = { + contextWindow: number; // input tokens + maxOutputTokens: number | null; // output tokens + cost: { + input: number; + output: number; + cache_read?: number; + cache_write?: number; + } + supportsSystemMessage: false | 'system-role' | 'developer-role' | 'separated'; + supportsTools: false | 'anthropic-style' | 'openai-style'; + supportsFIM: boolean; + + supportsReasoningOutput: false | { + // you are allowed to not include openSourceThinkTags if it's not open source (no such cases as of writing) + // if it's open source, put the think tags here so we parse them out in e.g. ollama + openSourceThinkTags?: [string, string] + }; +} + +type ProviderReasoningOptions = { + // include this in payload to get reasoning + input?: { includeInPayload?: { [key: string]: any }, }; + // nameOfFieldInDelta: reasoning output is in response.choices[0].delta[deltaReasoningField] + // needsManualParse: whether we must manually parse out the tags + output?: + | { nameOfFieldInDelta?: string, needsManualParse?: undefined, } + | { nameOfFieldInDelta?: undefined, needsManualParse?: true, }; +} + +type ProviderSettings = { + ifSupportsReasoningOutput?: ProviderReasoningOptions; + modelOptions: { [key: string]: ModelOptions }; + modelOptionsFallback: (modelName: string) => (ModelOptions & { modelName: string }) | null; +} + + +type ModelSettingsOfProvider = { + [providerName in ProviderName]: ProviderSettings +} + + + +// type DefaultModels = typeof defaultModelsOfProvider[T][number] +// type AssertModelsIncluded< +// T extends ProviderName, +// Options extends Record +// > = Exclude, keyof Options> extends never +// ? true +// : ["Missing models for", T, Exclude, keyof Options>]; +// const assertOpenAI: AssertModelsIncluded<'openAI', typeof openAIModelOptions> = true; + + +const modelOptionDefaults: ModelOptions = { + contextWindow: 32_000, + maxOutputTokens: null, + cost: { input: 0, output: 0 }, + supportsSystemMessage: false, + supportsTools: false, + supportsFIM: false, + supportsReasoningOutput: false, +} + +const invalidApiKeyMessage = (providerName: ProviderName) => `Invalid ${displayInfoOfProviderName(providerName).title} API key.` + + +// ---------------- OPENAI ---------------- +const openAIModelOptions = { // https://platform.openai.com/docs/pricing + 'o1': { + contextWindow: 128_000, + maxOutputTokens: 100_000, + cost: { input: 15.00, cache_read: 7.50, output: 60.00, }, + supportsFIM: false, + supportsTools: false, + supportsSystemMessage: 'developer-role', + supportsReasoningOutput: false, + }, + 'o3-mini': { + contextWindow: 200_000, + maxOutputTokens: 100_000, + cost: { input: 1.10, cache_read: 0.55, output: 4.40, }, + supportsFIM: false, + supportsTools: false, + supportsSystemMessage: 'developer-role', + supportsReasoningOutput: false, + }, + 'gpt-4o': { + contextWindow: 128_000, + maxOutputTokens: 16_384, + cost: { input: 2.50, cache_read: 1.25, output: 10.00, }, + supportsFIM: false, + supportsTools: 'openai-style', + supportsSystemMessage: 'system-role', + supportsReasoningOutput: false, + }, + 'o1-mini': { + contextWindow: 128_000, + maxOutputTokens: 65_536, + cost: { input: 1.10, cache_read: 0.55, output: 4.40, }, + supportsFIM: false, + supportsTools: false, + supportsSystemMessage: false, // does not support any system + supportsReasoningOutput: false, + }, + 'gpt-4o-mini': { + contextWindow: 128_000, + maxOutputTokens: 16_384, + cost: { input: 0.15, cache_read: 0.075, output: 0.60, }, + supportsFIM: false, + supportsTools: 'openai-style', + supportsSystemMessage: 'system-role', // ?? + supportsReasoningOutput: false, + }, +} as const satisfies { [s: string]: ModelOptions } + + +const openAISettings: ProviderSettings = { + modelOptions: openAIModelOptions, + modelOptionsFallback: (modelName) => { + let fallbackName: keyof typeof openAIModelOptions | null = null + if (modelName.includes('o1')) { fallbackName = 'o1' } + if (modelName.includes('o3-mini')) { fallbackName = 'o3-mini' } + if (modelName.includes('gpt-4o')) { fallbackName = 'gpt-4o' } + if (fallbackName) return { modelName: fallbackName, ...openAIModelOptions[fallbackName] } + return null + } +} + +// ---------------- ANTHROPIC ---------------- +const anthropicModelOptions = { + 'claude-3-5-sonnet-20241022': { + contextWindow: 200_000, + maxOutputTokens: 8_192, + cost: { input: 3.00, cache_read: 0.30, cache_write: 3.75, output: 15.00 }, + supportsFIM: false, + supportsSystemMessage: 'separated', + supportsTools: 'anthropic-style', + supportsReasoningOutput: false, + }, + 'claude-3-5-haiku-20241022': { + contextWindow: 200_000, + maxOutputTokens: 8_192, + cost: { input: 0.80, cache_read: 0.08, cache_write: 1.00, output: 4.00 }, + supportsFIM: false, + supportsSystemMessage: 'separated', + supportsTools: 'anthropic-style', + supportsReasoningOutput: false, + }, + 'claude-3-opus-20240229': { + contextWindow: 200_000, + maxOutputTokens: 4_096, + cost: { input: 15.00, cache_read: 1.50, cache_write: 18.75, output: 75.00 }, + supportsFIM: false, + supportsSystemMessage: 'separated', + supportsTools: 'anthropic-style', + supportsReasoningOutput: false, + }, + 'claude-3-sonnet-20240229': { // no point of using this, but including this for people who put it in + contextWindow: 200_000, cost: { input: 3.00, output: 15.00 }, + maxOutputTokens: 4_096, + supportsFIM: false, + supportsSystemMessage: 'separated', + supportsTools: 'anthropic-style', + supportsReasoningOutput: false, + } +} as const satisfies { [s: string]: ModelOptions } + +const anthropicSettings: ProviderSettings = { + modelOptions: anthropicModelOptions, + modelOptionsFallback: (modelName) => { + let fallbackName: keyof typeof anthropicModelOptions | null = null + if (modelName.includes('claude-3-5-sonnet')) fallbackName = 'claude-3-5-sonnet-20241022' + if (modelName.includes('claude-3-5-haiku')) fallbackName = 'claude-3-5-haiku-20241022' + if (modelName.includes('claude-3-opus')) fallbackName = 'claude-3-opus-20240229' + if (modelName.includes('claude-3-sonnet')) fallbackName = 'claude-3-sonnet-20240229' + if (fallbackName) return { modelName: fallbackName, ...anthropicModelOptions[fallbackName] } + return { modelName, ...modelOptionDefaults, maxOutputTokens: 4_096 } + } +} + + +// ---------------- XAI ---------------- +const xAIModelOptions = { + 'grok-2-latest': { + contextWindow: 131_072, + maxOutputTokens: null, // 131_072, + cost: { input: 2.00, output: 10.00 }, + supportsFIM: false, + supportsSystemMessage: 'system-role', + supportsTools: 'openai-style', + supportsReasoningOutput: false, + }, +} as const satisfies { [s: string]: ModelOptions } + +const xAISettings: ProviderSettings = { + modelOptions: xAIModelOptions, + modelOptionsFallback: (modelName) => { + let fallbackName: keyof typeof xAIModelOptions | null = null + if (modelName.includes('grok-2')) fallbackName = 'grok-2-latest' + if (fallbackName) return { modelName: fallbackName, ...xAIModelOptions[fallbackName] } + return null + } +} + + +// ---------------- GEMINI ---------------- +const geminiModelOptions = { // https://ai.google.dev/gemini-api/docs/pricing + 'gemini-2.0-flash': { + contextWindow: 1_048_576, + maxOutputTokens: null, // 8_192, + cost: { input: 0.10, output: 0.40 }, + supportsFIM: false, + supportsSystemMessage: 'system-role', + supportsTools: 'openai-style', // we are assuming OpenAI SDK when calling gemini + supportsReasoningOutput: false, + }, + 'gemini-2.0-flash-lite-preview-02-05': { + contextWindow: 1_048_576, + maxOutputTokens: null, // 8_192, + cost: { input: 0.075, output: 0.30 }, + supportsFIM: false, + supportsSystemMessage: 'system-role', + supportsTools: 'openai-style', + supportsReasoningOutput: false, + }, + 'gemini-1.5-flash': { + contextWindow: 1_048_576, + maxOutputTokens: null, // 8_192, + cost: { input: 0.075, output: 0.30 }, // TODO!!! price doubles after 128K tokens, we are NOT encoding that info right now + supportsFIM: false, + supportsSystemMessage: 'system-role', + supportsTools: 'openai-style', + supportsReasoningOutput: false, + }, + 'gemini-1.5-pro': { + contextWindow: 2_097_152, + maxOutputTokens: null, // 8_192, + cost: { input: 1.25, output: 5.00 }, // TODO!!! price doubles after 128K tokens, we are NOT encoding that info right now + supportsFIM: false, + supportsSystemMessage: 'system-role', + supportsTools: 'openai-style', + supportsReasoningOutput: false, + }, + 'gemini-1.5-flash-8b': { + contextWindow: 1_048_576, + maxOutputTokens: null, // 8_192, + cost: { input: 0.0375, output: 0.15 }, // TODO!!! price doubles after 128K tokens, we are NOT encoding that info right now + supportsFIM: false, + supportsSystemMessage: 'system-role', + supportsTools: 'openai-style', + supportsReasoningOutput: false, + }, +} as const satisfies { [s: string]: ModelOptions } + +const geminiSettings: ProviderSettings = { + modelOptions: geminiModelOptions, + modelOptionsFallback: (modelName) => { + return null + } +} + + +// ---------------- OPEN SOURCE MODELS ---------------- + +const openSourceModelDefaultOptionsAssumingOAICompat = { + 'deepseekR1': { + supportsFIM: false, + supportsSystemMessage: false, + supportsTools: false, + supportsReasoningOutput: { openSourceThinkTags: ['', ''] }, + }, + 'deepseekCoderV2': { + supportsFIM: false, + supportsSystemMessage: false, // unstable + supportsTools: false, + supportsReasoningOutput: false, + }, + 'codestral': { + supportsFIM: true, + supportsSystemMessage: 'system-role', + supportsTools: 'openai-style', + supportsReasoningOutput: false, + }, + // llama + 'llama3': { + supportsFIM: false, + supportsSystemMessage: 'system-role', + supportsTools: 'openai-style', + supportsReasoningOutput: false, + }, + 'llama3.1': { + supportsFIM: false, + supportsSystemMessage: 'system-role', + supportsTools: 'openai-style', + supportsReasoningOutput: false, + }, + 'llama3.2': { + supportsFIM: false, + supportsSystemMessage: 'system-role', + supportsTools: 'openai-style', + supportsReasoningOutput: false, + }, + 'llama3.3': { + supportsFIM: false, + supportsSystemMessage: 'system-role', + supportsTools: 'openai-style', + supportsReasoningOutput: false, + }, + 'qwen2.5coder': { + supportsFIM: true, + supportsSystemMessage: 'system-role', + supportsTools: 'openai-style', + supportsReasoningOutput: false, + }, + // FIM only + 'starcoder2': { + supportsFIM: true, + supportsSystemMessage: false, + supportsTools: false, + supportsReasoningOutput: false, + }, + 'codegemma:2b': { + supportsFIM: true, + supportsSystemMessage: false, + supportsTools: false, + supportsReasoningOutput: false, + }, +} as const satisfies { [s: string]: Partial } + + + +// ---------------- DEEPSEEK API ---------------- +const deepseekModelOptions = { + 'deepseek-chat': { + ...openSourceModelDefaultOptionsAssumingOAICompat.deepseekR1, + contextWindow: 64_000, // https://api-docs.deepseek.com/quick_start/pricing + maxOutputTokens: null, // 8_000, + cost: { cache_read: .07, input: .27, output: 1.10, }, + }, + 'deepseek-reasoner': { + ...openSourceModelDefaultOptionsAssumingOAICompat.deepseekCoderV2, + contextWindow: 64_000, + maxOutputTokens: null, // 8_000, + cost: { cache_read: .14, input: .55, output: 2.19, }, + }, +} as const satisfies { [s: string]: ModelOptions } + + +const deepseekSettings: ProviderSettings = { + modelOptions: deepseekModelOptions, + ifSupportsReasoningOutput: { + // reasoning: OAICompat + response.choices[0].delta.reasoning_content // https://api-docs.deepseek.com/guides/reasoning_model + output: { nameOfFieldInDelta: 'reasoning_content' }, + }, + modelOptionsFallback: (modelName) => { + return null + } +} + +// ---------------- GROQ ---------------- +const groqModelOptions = { + 'llama-3.3-70b-versatile': { + contextWindow: 128_000, + maxOutputTokens: null, // 32_768, + cost: { input: 0.59, output: 0.79 }, + supportsFIM: false, + supportsSystemMessage: 'system-role', + supportsTools: 'openai-style', + supportsReasoningOutput: false, + }, + 'llama-3.1-8b-instant': { + contextWindow: 128_000, + maxOutputTokens: null, // 8_192, + cost: { input: 0.05, output: 0.08 }, + supportsFIM: false, + supportsSystemMessage: 'system-role', + supportsTools: 'openai-style', + supportsReasoningOutput: false, + }, + 'qwen-2.5-coder-32b': { + contextWindow: 128_000, + maxOutputTokens: null, // not specified? + cost: { input: 0.79, output: 0.79 }, + supportsFIM: false, // unfortunately looks like no FIM support on groq + supportsSystemMessage: 'system-role', + supportsTools: 'openai-style', + supportsReasoningOutput: false, + }, +} as const satisfies { [s: string]: ModelOptions } +const groqSettings: ProviderSettings = { + modelOptions: groqModelOptions, + modelOptionsFallback: (modelName) => { return null } +} + + +// ---------------- anything self-hosted/local: VLLM, OLLAMA, OPENAICOMPAT ---------------- + +// fallback to any model (anything openai-compatible) +const extensiveModelFallback: ProviderSettings['modelOptionsFallback'] = (modelName) => { + const toFallback = (opts: Omit): ModelOptions & { modelName: string } => { + return { + modelName, + ...opts, + supportsSystemMessage: opts.supportsSystemMessage ? 'system-role' : false, + cost: { input: 0, output: 0 }, + } + } + if (modelName.includes('gpt-4o')) return toFallback(openAIModelOptions['gpt-4o']) + if (modelName.includes('claude')) return toFallback(anthropicModelOptions['claude-3-5-sonnet-20241022']) + if (modelName.includes('grok')) return toFallback(xAIModelOptions['grok-2-latest']) + if (modelName.includes('deepseek-r1') || modelName.includes('deepseek-reasoner')) return toFallback({ ...openSourceModelDefaultOptionsAssumingOAICompat.deepseekR1, contextWindow: 32_000, maxOutputTokens: 4_096, }) + if (modelName.includes('deepseek')) return toFallback({ ...openSourceModelDefaultOptionsAssumingOAICompat.deepseekCoderV2, contextWindow: 32_000, maxOutputTokens: 4_096, }) + if (modelName.includes('llama3')) return toFallback({ ...openSourceModelDefaultOptionsAssumingOAICompat.llama3, contextWindow: 32_000, maxOutputTokens: 4_096, }) + if (modelName.includes('qwen') && modelName.includes('2.5') && modelName.includes('coder')) return toFallback({ ...openSourceModelDefaultOptionsAssumingOAICompat['qwen2.5coder'], contextWindow: 32_000, maxOutputTokens: 4_096, }) + if (modelName.includes('codestral')) return toFallback({ ...openSourceModelDefaultOptionsAssumingOAICompat.codestral, contextWindow: 32_000, maxOutputTokens: 4_096, }) + if (/\bo1\b/.test(modelName) || /\bo3\b/.test(modelName)) return toFallback(openAIModelOptions['o1']) + return toFallback(modelOptionDefaults) +} + + +const vLLMSettings: ProviderSettings = { + // reasoning: OAICompat + response.choices[0].delta.reasoning_content // https://docs.vllm.ai/en/stable/features/reasoning_outputs.html#streaming-chat-completions + ifSupportsReasoningOutput: { output: { nameOfFieldInDelta: 'reasoning_content' }, }, + modelOptionsFallback: (modelName) => extensiveModelFallback(modelName), + modelOptions: {}, +} + +const ollamaSettings: ProviderSettings = { + // reasoning: we need to filter out reasoning tags manually + ifSupportsReasoningOutput: { output: { needsManualParse: true }, }, + modelOptionsFallback: (modelName) => extensiveModelFallback(modelName), + modelOptions: {}, +} + +const openaiCompatible: ProviderSettings = { + // reasoning: we have no idea what endpoint they used, so we can't consistently parse out reasoning + modelOptionsFallback: (modelName) => extensiveModelFallback(modelName), + modelOptions: {}, +} + + +// ---------------- OPENROUTER ---------------- +const openRouterModelOptions = { + 'deepseek/deepseek-r1': { + ...openSourceModelDefaultOptionsAssumingOAICompat.deepseekR1, + contextWindow: 128_000, + maxOutputTokens: null, + cost: { input: 0.8, output: 2.4 }, + }, + 'anthropic/claude-3.5-sonnet': { + contextWindow: 200_000, + maxOutputTokens: null, + cost: { input: 3.00, output: 15.00 }, + supportsFIM: false, + supportsSystemMessage: 'system-role', + supportsTools: 'openai-style', + supportsReasoningOutput: false, + }, + 'mistralai/codestral-2501': { + ...openSourceModelDefaultOptionsAssumingOAICompat.codestral, + contextWindow: 256_000, + maxOutputTokens: null, + cost: { input: 0.3, output: 0.9 }, + supportsTools: 'openai-style', + supportsReasoningOutput: false, + }, + 'qwen/qwen-2.5-coder-32b-instruct': { + ...openSourceModelDefaultOptionsAssumingOAICompat['qwen2.5coder'], + contextWindow: 33_000, + maxOutputTokens: null, + supportsTools: false, // openrouter qwen doesn't seem to support tools...? + cost: { input: 0.07, output: 0.16 }, + } + + +} as const satisfies { [s: string]: ModelOptions } + +const openRouterSettings: ProviderSettings = { + // reasoning: OAICompat + response.choices[0].delta.reasoning : payload should have {include_reasoning: true} https://openrouter.ai/announcements/reasoning-tokens-for-thinking-models + ifSupportsReasoningOutput: { + input: { includeInPayload: { include_reasoning: true } }, + output: { nameOfFieldInDelta: 'reasoning' }, + }, + modelOptions: openRouterModelOptions, + // TODO!!! send a query to openrouter to get the price, isFIM, etc. + modelOptionsFallback: (modelName) => extensiveModelFallback(modelName), +} + +// ---------------- model settings of everything above ---------------- + +const modelSettingsOfProvider: ModelSettingsOfProvider = { + openAI: openAISettings, + anthropic: anthropicSettings, + xAI: xAISettings, + gemini: geminiSettings, + + // open source models + deepseek: deepseekSettings, + groq: groqSettings, + + // open source models + providers (mixture of everything) + openRouter: openRouterSettings, + vLLM: vLLMSettings, + ollama: ollamaSettings, + openAICompatible: openaiCompatible, + + // googleVertex: {}, + // microsoftAzure: {}, +} as const satisfies ModelSettingsOfProvider + + + + +export const getModelCapabilities = (providerName: ProviderName, modelName: string): ModelOptions & { modelName: string } => { + const { modelOptions, modelOptionsFallback } = modelSettingsOfProvider[providerName] + if (modelName in modelOptions) return { modelName, ...modelOptions[modelName] } + const result = modelOptionsFallback(modelName) + if (!result) return { modelName, ...modelOptionDefaults } + return result +} + + + +type InternalCommonMessageParams = { + aiInstructions: string; + onText: OnText; + onFinalMessage: OnFinalMessage; + onError: OnError; + providerName: ProviderName; + settingsOfProvider: SettingsOfProvider; + modelName: string; + _setAborter: (aborter: () => void) => void; +} + +type SendChatParams_Internal = InternalCommonMessageParams & { messages: LLMChatMessage[]; tools?: InternalToolInfo[] } +type SendFIMParams_Internal = InternalCommonMessageParams & { messages: LLMFIMMessage; } +export type ListParams_Internal = ModelListParams + + +// ------------ OPENAI-COMPATIBLE (HELPERS) ------------ +const toOpenAICompatibleTool = (toolInfo: InternalToolInfo) => { + const { name, description, params, required } = toolInfo + return { + type: 'function', + function: { + name: name, + description: description, + parameters: { + type: 'object', + properties: params, + required: required, + } + } + } satisfies OpenAI.Chat.Completions.ChatCompletionTool +} + +type ToolCallOfIndex = { [index: string]: { name: string, params: string, id: string } } + +const toolCallsFrom_OpenAICompat = (toolCallOfIndex: ToolCallOfIndex) => { + return Object.keys(toolCallOfIndex).map(index => { + const tool = toolCallOfIndex[index] + return isAToolName(tool.name) ? { name: tool.name, id: tool.id, params: tool.params } : null + }).filter(t => !!t) +} + + +const newOpenAICompatibleSDK = ({ settingsOfProvider, providerName, includeInPayload }: { settingsOfProvider: SettingsOfProvider, providerName: ProviderName, includeInPayload?: { [s: string]: any } }) => { + const commonPayloadOpts: ClientOptions = { + dangerouslyAllowBrowser: true, + ...includeInPayload, + } + if (providerName === 'openAI') { + const thisConfig = settingsOfProvider[providerName] + return new OpenAI({ apiKey: thisConfig.apiKey, ...commonPayloadOpts }) + } + else if (providerName === 'ollama') { + const thisConfig = settingsOfProvider[providerName] + return new OpenAI({ baseURL: `${thisConfig.endpoint}/v1`, apiKey: 'noop', ...commonPayloadOpts }) + } + else if (providerName === 'vLLM') { + const thisConfig = settingsOfProvider[providerName] + return new OpenAI({ baseURL: `${thisConfig.endpoint}/v1`, apiKey: 'noop', ...commonPayloadOpts }) + } + else if (providerName === 'openRouter') { + const thisConfig = settingsOfProvider[providerName] + return new OpenAI({ + baseURL: 'https://openrouter.ai/api/v1', + apiKey: thisConfig.apiKey, + defaultHeaders: { + 'HTTP-Referer': 'https://voideditor.com', // Optional, for including your app on openrouter.ai rankings. + 'X-Title': 'Void', // Optional. Shows in rankings on openrouter.ai. + }, + ...commonPayloadOpts, + }) + } + else if (providerName === 'gemini') { + const thisConfig = settingsOfProvider[providerName] + return new OpenAI({ baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai', apiKey: thisConfig.apiKey, ...commonPayloadOpts }) + } + else if (providerName === 'deepseek') { + const thisConfig = settingsOfProvider[providerName] + return new OpenAI({ baseURL: 'https://api.deepseek.com/v1', apiKey: thisConfig.apiKey, ...commonPayloadOpts }) + } + else if (providerName === 'openAICompatible') { + const thisConfig = settingsOfProvider[providerName] + return new OpenAI({ baseURL: thisConfig.endpoint, apiKey: thisConfig.apiKey, ...commonPayloadOpts }) + } + else if (providerName === 'groq') { + const thisConfig = settingsOfProvider[providerName] + return new OpenAI({ baseURL: 'https://api.groq.com/openai/v1', apiKey: thisConfig.apiKey, ...commonPayloadOpts }) + } + else if (providerName === 'xAI') { + const thisConfig = settingsOfProvider[providerName] + return new OpenAI({ baseURL: 'https://api.x.ai/v1', apiKey: thisConfig.apiKey, ...commonPayloadOpts }) + } + + else throw new Error(`Void providerName was invalid: ${providerName}.`) +} + + + +const _sendOpenAICompatibleFIM = ({ messages: messages_, onFinalMessage, onError, settingsOfProvider, modelName: modelName_, _setAborter, providerName, aiInstructions, }: SendFIMParams_Internal) => { + const { modelName, supportsFIM } = getModelCapabilities(providerName, modelName_) + if (!supportsFIM) { + if (modelName === modelName_) + onFinalMessage({ fullText: `Model ${modelName} does not support FIM.` }) + else + onFinalMessage({ fullText: `Model ${modelName_} (${modelName}) does not support FIM.` }) + return + } + + const messages = prepareFIMMessage({ messages: messages_, aiInstructions, }) + + const openai = newOpenAICompatibleSDK({ providerName, settingsOfProvider }) + openai.completions + .create({ + model: modelName, + prompt: messages.prefix, + suffix: messages.suffix, + stop: messages.stopTokens, + max_tokens: messages.maxTokens, + }) + .then(async response => { + const fullText = response.choices[0]?.text + onFinalMessage({ fullText, }); + }) + .catch(error => { + if (error instanceof OpenAI.APIError && error.status === 401) { onError({ message: invalidApiKeyMessage(providerName), fullError: error }); } + else { onError({ message: error + '', fullError: error }); } + }) +} + + + + +const _sendOpenAICompatibleChat = ({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelName: modelName_, _setAborter, providerName, aiInstructions, tools: tools_ }: SendChatParams_Internal) => { + const { + modelName, + supportsReasoningOutput, + supportsSystemMessage, + supportsTools, + maxOutputTokens, + } = getModelCapabilities(providerName, modelName_) + + const { messages } = prepareMessages({ messages: messages_, aiInstructions, supportsSystemMessage, supportsTools, }) + const tools = (supportsTools && ((tools_?.length ?? 0) !== 0)) ? tools_?.map(tool => toOpenAICompatibleTool(tool)) : undefined + + const includeInPayload = supportsReasoningOutput ? modelSettingsOfProvider[providerName].ifSupportsReasoningOutput?.input?.includeInPayload || {} : {} + + const toolsObj = tools ? { tools: tools, tool_choice: 'auto', parallel_tool_calls: false, } as const : {} + const maxTokensObj = maxOutputTokens ? { max_tokens: maxOutputTokens } : {} + const openai: OpenAI = newOpenAICompatibleSDK({ providerName, settingsOfProvider, includeInPayload }) + const options: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { model: modelName, messages: messages, stream: true, ...toolsObj, ...maxTokensObj } + + const { nameOfFieldInDelta: nameOfReasoningFieldInDelta, needsManualParse: needsManualReasoningParse } = modelSettingsOfProvider[providerName].ifSupportsReasoningOutput?.output ?? {} + if (needsManualReasoningParse && supportsReasoningOutput && supportsReasoningOutput.openSourceThinkTags) + onText = extractReasoningFromText(onText, supportsReasoningOutput.openSourceThinkTags) + + let fullReasoning = '' + let fullText = '' + const toolCallOfIndex: ToolCallOfIndex = {} + openai.chat.completions + .create(options) + .then(async response => { + _setAborter(() => response.controller.abort()) + // when receive text + for await (const chunk of response) { + // tool call + for (const tool of chunk.choices[0]?.delta?.tool_calls ?? []) { + const index = tool.index + if (!toolCallOfIndex[index]) toolCallOfIndex[index] = { name: '', params: '', id: '' } + toolCallOfIndex[index].name += tool.function?.name ?? '' + toolCallOfIndex[index].params += tool.function?.arguments ?? ''; + toolCallOfIndex[index].id = tool.id ?? '' + } + // message + const newText = chunk.choices[0]?.delta?.content ?? '' + fullText += newText + + // reasoning + let newReasoning = '' + if (nameOfReasoningFieldInDelta) { + // @ts-ignore + newReasoning = (chunk.choices[0]?.delta?.[nameOfReasoningFieldInDelta] || '') + '' + fullReasoning += newReasoning + } + + onText({ newText, fullText, newReasoning, fullReasoning }) + } + onFinalMessage({ fullText, toolCalls: toolCallsFrom_OpenAICompat(toolCallOfIndex) }); + }) + // when error/fail - this catches errors of both .create() and .then(for await) + .catch(error => { + if (error instanceof OpenAI.APIError && error.status === 401) { onError({ message: invalidApiKeyMessage(providerName), fullError: error }); } + else { onError({ message: error + '', fullError: error }); } + }) +} + + +const _openaiCompatibleList = async ({ onSuccess: onSuccess_, onError: onError_, settingsOfProvider, providerName }: ListParams_Internal) => { + const onSuccess = ({ models }: { models: OpenAIModel[] }) => { + onSuccess_({ models }) + } + const onError = ({ error }: { error: string }) => { + onError_({ error }) + } + try { + const openai = newOpenAICompatibleSDK({ providerName, settingsOfProvider }) + openai.models.list() + .then(async (response) => { + const models: OpenAIModel[] = [] + models.push(...response.data) + while (response.hasNextPage()) { + models.push(...(await response.getNextPage()).data) + } + onSuccess({ models }) + }) + .catch((error) => { + onError({ error: error + '' }) + }) + } + catch (error) { + onError({ error: error + '' }) + } +} + + + + +// ------------ ANTHROPIC ------------ +const toAnthropicTool = (toolInfo: InternalToolInfo) => { + const { name, description, params, required } = toolInfo + return { + name: name, + description: description, + input_schema: { + type: 'object', + properties: params, + required: required, + } + } satisfies Anthropic.Messages.Tool +} + +const toolCallsFromAnthropicContent = (content: Anthropic.Messages.ContentBlock[]) => { + return content.map(c => { + if (c.type !== 'tool_use') return null + if (!isAToolName(c.name)) return null + return c.type === 'tool_use' ? { name: c.name, params: JSON.stringify(c.input), id: c.id } : null + }).filter(t => !!t) +} + +const sendAnthropicChat = ({ messages: messages_, onText, providerName, onFinalMessage, onError, settingsOfProvider, modelName: modelName_, _setAborter, aiInstructions, tools: tools_ }: SendChatParams_Internal) => { + const { + // supportsReasoning: modelSupportsReasoning, + modelName, + supportsSystemMessage, + supportsTools, + maxOutputTokens, + } = getModelCapabilities(providerName, modelName_) + + const { messages, separateSystemMessageStr } = prepareMessages({ messages: messages_, aiInstructions, supportsSystemMessage, supportsTools, }) + + const thisConfig = settingsOfProvider.anthropic + const anthropic = new Anthropic({ apiKey: thisConfig.apiKey, dangerouslyAllowBrowser: true }); + const tools = ((tools_?.length ?? 0) !== 0) ? tools_?.map(tool => toAnthropicTool(tool)) : undefined + + const stream = anthropic.messages.stream({ + system: separateSystemMessageStr, + messages: messages, + model: modelName, + max_tokens: maxOutputTokens ?? 4_096, // anthropic requires this + tools: tools, + tool_choice: tools ? { type: 'auto', disable_parallel_tool_use: true } : undefined // one tool use at a time + }) + // when receive text + stream.on('text', (newText, fullText) => { + onText({ newText, fullText, newReasoning: '', fullReasoning: '' }) + }) + // when we get the final message on this stream (or when error/fail) + stream.on('finalMessage', (response) => { + const content = response.content.map(c => c.type === 'text' ? c.text : '').join('\n\n') + const toolCalls = toolCallsFromAnthropicContent(response.content) + onFinalMessage({ fullText: content, toolCalls }) + }) + // on error + stream.on('error', (error) => { + if (error instanceof Anthropic.APIError && error.status === 401) { onError({ message: invalidApiKeyMessage(providerName), fullError: error }) } + else { onError({ message: error + '', fullError: error }) } + }) + _setAborter(() => stream.controller.abort()) +} + +// // in future, can do tool_use streaming in anthropic, but it's pretty fast even without streaming... +// const toolCallOfIndex: { [index: string]: { name: string, args: string } } = {} +// stream.on('streamEvent', e => { +// if (e.type === 'content_block_start') { +// if (e.content_block.type !== 'tool_use') return +// const index = e.index +// if (!toolCallOfIndex[index]) toolCallOfIndex[index] = { name: '', args: '' } +// toolCallOfIndex[index].name += e.content_block.name ?? '' +// toolCallOfIndex[index].args += e.content_block.input ?? '' +// } +// else if (e.type === 'content_block_delta') { +// if (e.delta.type !== 'input_json_delta') return +// toolCallOfIndex[e.index].args += e.delta.partial_json +// } +// }) + + +// ------------ OLLAMA ------------ +const newOllamaSDK = ({ endpoint }: { endpoint: string }) => { + // if endpoint is empty, normally ollama will send to 11434, but we want it to fail - the user should type it in + if (!endpoint) throw new Error(`Ollama Endpoint was empty (please enter ${defaultProviderSettings.ollama.endpoint} in Void if you want the default url).`) + const ollama = new Ollama({ host: endpoint }) + return ollama +} + +const ollamaList = async ({ onSuccess: onSuccess_, onError: onError_, settingsOfProvider }: ListParams_Internal) => { + const onSuccess = ({ models }: { models: OllamaModelResponse[] }) => { + onSuccess_({ models }) + } + const onError = ({ error }: { error: string }) => { + onError_({ error }) + } + try { + const thisConfig = settingsOfProvider.ollama + const ollama = newOllamaSDK({ endpoint: thisConfig.endpoint }) + ollama.list() + .then((response) => { + const { models } = response + onSuccess({ models }) + }) + .catch((error) => { + onError({ error: error + '' }) + }) + } + catch (error) { + onError({ error: error + '' }) + } +} + +const sendOllamaFIM = ({ messages: messages_, onFinalMessage, onError, settingsOfProvider, modelName, aiInstructions, _setAborter }: SendFIMParams_Internal) => { + const thisConfig = settingsOfProvider.ollama + const ollama = newOllamaSDK({ endpoint: thisConfig.endpoint }) + + const messages = prepareFIMMessage({ messages: messages_, aiInstructions, }) + + let fullText = '' + ollama.generate({ + model: modelName, + prompt: messages.prefix, + suffix: messages.suffix, + options: { + stop: messages.stopTokens, + num_predict: messages.maxTokens, // max tokens + // repeat_penalty: 1, + }, + raw: true, + stream: true, // stream is not necessary but lets us expose the + }) + .then(async stream => { + _setAborter(() => stream.abort()) + for await (const chunk of stream) { + const newText = chunk.response + fullText += newText + } + onFinalMessage({ fullText }) + }) + // when error/fail + .catch((error) => { + onError({ message: error + '', fullError: error }) + }) +} + + + +type CallFnOfProvider = { + [providerName in ProviderName]: { + sendChat: (params: SendChatParams_Internal) => void; + sendFIM: ((params: SendFIMParams_Internal) => void) | null; + list: ((params: ListParams_Internal) => void) | null; + } +} + +export const sendLLMMessageToProviderImplementation = { + anthropic: { + sendChat: sendAnthropicChat, + sendFIM: null, + list: null, + }, + openAI: { + sendChat: (params) => _sendOpenAICompatibleChat(params), + sendFIM: null, + list: null, + }, + xAI: { + sendChat: (params) => _sendOpenAICompatibleChat(params), + sendFIM: null, + list: null, + }, + gemini: { + sendChat: (params) => _sendOpenAICompatibleChat(params), + sendFIM: null, + list: null, + }, + ollama: { + sendChat: (params) => _sendOpenAICompatibleChat(params), + sendFIM: sendOllamaFIM, + list: ollamaList, + }, + openAICompatible: { + sendChat: (params) => _sendOpenAICompatibleChat(params), // using openai's SDK is not ideal (your implementation might not do tools, reasoning, FIM etc correctly), talk to us for a custom integration + sendFIM: (params) => _sendOpenAICompatibleFIM(params), + list: null, + }, + openRouter: { + sendChat: (params) => _sendOpenAICompatibleChat(params), + sendFIM: (params) => _sendOpenAICompatibleFIM(params), + list: null, + }, + vLLM: { + sendChat: (params) => _sendOpenAICompatibleChat(params), + sendFIM: (params) => _sendOpenAICompatibleFIM(params), + list: (params) => _openaiCompatibleList(params), + }, + deepseek: { + sendChat: (params) => _sendOpenAICompatibleChat(params), + sendFIM: null, + list: null, + }, + groq: { + sendChat: (params) => _sendOpenAICompatibleChat(params), + sendFIM: null, + list: null, + }, +} satisfies CallFnOfProvider + + + + +/* +FIM info (this may be useful in the future with vLLM, but in most cases the only way to use FIM is if the provider explicitly supports it): + +qwen2.5-coder https://ollama.com/library/qwen2.5-coder/blobs/e94a8ecb9327 +<|fim_prefix|>{{ .Prompt }}<|fim_suffix|>{{ .Suffix }}<|fim_middle|> + +codestral https://ollama.com/library/codestral/blobs/51707752a87c +[SUFFIX]{{ .Suffix }}[PREFIX] {{ .Prompt }} + +deepseek-coder-v2 https://ollama.com/library/deepseek-coder-v2/blobs/22091531faf0 +<|fim▁begin|>{{ .Prompt }}<|fim▁hole|>{{ .Suffix }}<|fim▁end|> + +starcoder2 https://ollama.com/library/starcoder2/blobs/3b190e68fefe + + +{{ .Prompt }}{{ .Suffix }} +<|end_of_text|> + +codegemma https://ollama.com/library/codegemma:2b/blobs/48d9a8140749 +<|fim_prefix|>{{ .Prompt }}<|fim_suffix|>{{ .Suffix }}<|fim_middle|> + +*/ diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/_old.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/_old.ts deleted file mode 100644 index e1e90245646..00000000000 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/_old.ts +++ /dev/null @@ -1,96 +0,0 @@ -// /*-------------------------------------------------------------------------------------- -// * Copyright 2025 Glass Devtools, Inc. All rights reserved. -// * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. -// *--------------------------------------------------------------------------------------*/ - -// import Groq from 'groq-sdk'; -// import { _InternalSendLLMChatMessageFnType } from '../../common/llmMessageTypes.js'; - -// // Groq -// export const sendGroqChat: _InternalSendLLMChatMessageFnType = async ({ messages, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter }) => { -// let fullText = ''; - -// const thisConfig = settingsOfProvider.groq - -// const groq = new Groq({ -// apiKey: thisConfig.apiKey, -// dangerouslyAllowBrowser: true -// }); - -// await groq.chat.completions -// .create({ -// messages: messages, -// model: modelName, -// stream: true, -// }) -// .then(async response => { -// _setAborter(() => response.controller.abort()) -// // when receive text -// for await (const chunk of response) { -// const newText = chunk.choices[0]?.delta?.content || ''; -// fullText += newText; -// onText({ newText, fullText }); -// } - -// onFinalMessage({ fullText, tools: [] }); -// }) -// .catch(error => { -// onError({ message: error + '', fullError: error }); -// }) - - -// }; - - - -// /*-------------------------------------------------------------------------------------- -// * Copyright 2025 Glass Devtools, Inc. All rights reserved. -// * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. -// *--------------------------------------------------------------------------------------*/ - -// import { Mistral } from '@mistralai/mistralai'; -// import { _InternalSendLLMChatMessageFnType } from '../../common/llmMessageTypes.js'; - -// // Mistral -// export const sendMistralChat: _InternalSendLLMChatMessageFnType = async ({ messages, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter }) => { -// let fullText = ''; - -// const thisConfig = settingsOfProvider.mistral; - -// const mistral = new Mistral({ -// apiKey: thisConfig.apiKey, -// }) - -// await mistral.chat -// .stream({ -// messages: messages, -// model: modelName, -// stream: true, -// }) -// .then(async response => { -// // Mistral has a really nonstandard API - no interrupt and weird stream types -// _setAborter(() => { console.log('Mistral does not support interrupts! Further messages will just be ignored.') }); -// // when receive text -// for await (const chunk of response) { -// const c = chunk.data.choices[0].delta.content || '' -// const newText = ( -// typeof c === 'string' ? c -// : c?.map(c => c.type === 'text' ? c.text : c.type).join('\n') -// ) -// fullText += newText; -// onText({ newText, fullText }); -// } - -// onFinalMessage({ fullText, tools: [] }); -// }) -// .catch(error => { -// onError({ message: error + '', fullError: error }); -// }) -// } - - - - - - - diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/anthropic.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/anthropic.ts deleted file mode 100644 index c4338ebb796..00000000000 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/anthropic.ts +++ /dev/null @@ -1,114 +0,0 @@ -/*-------------------------------------------------------------------------------------- - * Copyright 2025 Glass Devtools, Inc. All rights reserved. - * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. - *--------------------------------------------------------------------------------------*/ - -import Anthropic from '@anthropic-ai/sdk'; -import { _InternalSendLLMChatMessageFnType } from '../../common/llmMessageTypes.js'; -import { anthropicMaxPossibleTokens, developerInfoOfModelName, developerInfoOfProviderName } from '../../common/voidSettingsTypes.js'; -import { InternalToolInfo } from '../../common/toolsService.js'; -import { addSystemMessageAndToolSupport } from './preprocessLLMMessages.js'; -import { isAToolName } from './postprocessToolCalls.js'; - - - - -export const toAnthropicTool = (toolInfo: InternalToolInfo) => { - const { name, description, params, required } = toolInfo - return { - name: name, - description: description, - input_schema: { - type: 'object', - properties: params, - required: required, - } - } satisfies Anthropic.Messages.Tool -} - - - - - -export const sendAnthropicChat: _InternalSendLLMChatMessageFnType = ({ messages: messages_, providerName, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, aiInstructions, tools: tools_ }) => { - - const thisConfig = settingsOfProvider.anthropic - - const maxTokens = anthropicMaxPossibleTokens(modelName) - if (maxTokens === undefined) { - onError({ message: `Please set a value for Max Tokens.`, fullError: null }) - return - } - - const { messages, separateSystemMessageStr } = addSystemMessageAndToolSupport(modelName, providerName, messages_, aiInstructions, { separateSystemMessage: true }) - - const { overrideSettingsForAllModels } = developerInfoOfProviderName(providerName) - const { supportsTools } = developerInfoOfModelName(modelName, overrideSettingsForAllModels) - - const anthropic = new Anthropic({ apiKey: thisConfig.apiKey, dangerouslyAllowBrowser: true }); - - const tools = (supportsTools && ((tools_?.length ?? 0) !== 0)) ? tools_?.map(tool => toAnthropicTool(tool)) : undefined - - const stream = anthropic.messages.stream({ - system: separateSystemMessageStr, - messages: messages, - model: modelName, - max_tokens: maxTokens, - tools: tools, - tool_choice: tools ? { type: 'auto', disable_parallel_tool_use: true } : undefined // one tool use at a time - }) - - - // when receive text - stream.on('text', (newText, fullText) => { - onText({ newText, fullText }) - }) - - - // // can do tool use streaming - // const toolCallOfIndex: { [index: string]: { name: string, args: string } } = {} - // stream.on('streamEvent', e => { - // if (e.type === 'content_block_start') { - // if (e.content_block.type !== 'tool_use') return - // const index = e.index - // if (!toolCallOfIndex[index]) toolCallOfIndex[index] = { name: '', args: '' } - // toolCallOfIndex[index].name += e.content_block.name ?? '' - // toolCallOfIndex[index].args += e.content_block.input ?? '' - // } - // else if (e.type === 'content_block_delta') { - // if (e.delta.type !== 'input_json_delta') return - // toolCallOfIndex[e.index].args += e.delta.partial_json - // } - // // TODO!!!!! - // // onText({}) - // }) - - // when we get the final message on this stream (or when error/fail) - stream.on('finalMessage', (response) => { - // stringify the response's content - const content = response.content.map(c => c.type === 'text' ? c.text : '').join('\n\n') - const toolCalls = response.content - .map(c => { - if (c.type !== 'tool_use') return null - if (!isAToolName(c.name)) return null - return c.type === 'tool_use' ? { name: c.name, params: JSON.stringify(c.input), id: c.id } : null - }) - .filter(t => !!t) - - onFinalMessage({ fullText: content, toolCalls }) - }) - - stream.on('error', (error) => { - // the most common error will be invalid API key (401), so we handle this with a nice message - if (error instanceof Anthropic.APIError && error.status === 401) { - onError({ message: 'Invalid API key.', fullError: error }) - } - else { - onError({ message: error + '', fullError: error }) // anthropic errors can be stringified nicely like this - } - }) - - // TODO need to test this to make sure it works, it might throw an error - _setAborter(() => stream.controller.abort()) - -}; diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/ollama.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/ollama.ts deleted file mode 100644 index da6715c036d..00000000000 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/ollama.ts +++ /dev/null @@ -1,124 +0,0 @@ - -/*-------------------------------------------------------------------------------------- - * Copyright 2025 Glass Devtools, Inc. All rights reserved. - * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. - *--------------------------------------------------------------------------------------*/ - -import { Ollama } from 'ollama'; -import { _InternalModelListFnType, _InternalSendLLMFIMMessageFnType, _InternalSendLLMChatMessageFnType, OllamaModelResponse } from '../../common/llmMessageTypes.js'; -import { defaultProviderSettings } from '../../common/voidSettingsTypes.js'; - -export const ollamaList: _InternalModelListFnType = async ({ onSuccess: onSuccess_, onError: onError_, settingsOfProvider }) => { - - const onSuccess = ({ models }: { models: OllamaModelResponse[] }) => { - onSuccess_({ models }) - } - - const onError = ({ error }: { error: string }) => { - onError_({ error }) - } - - try { - const thisConfig = settingsOfProvider.ollama - // if endpoint is empty, normally ollama will send to 11434, but we want it to fail - the user should type it in - if (!thisConfig.endpoint) throw new Error(`Ollama Endpoint was empty (please enter ${defaultProviderSettings.ollama.endpoint} in Void if you want the default url).`) - - const ollama = new Ollama({ host: thisConfig.endpoint }) - ollama.list() - .then((response) => { - const { models } = response - onSuccess({ models }) - }) - .catch((error) => { - onError({ error: error + '' }) - }) - } - catch (error) { - onError({ error: error + '' }) - } -} - - - - -// export const sendOllamaFIM: _InternalSendLLMFIMMessageFnType = ({ messages, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter }) => { - -// const thisConfig = settingsOfProvider.ollama -// // if endpoint is empty, normally ollama will send to 11434, but we want it to fail - the user should type it in -// if (!thisConfig.endpoint) throw new Error(`Ollama Endpoint was empty (please enter ${defaultProviderSettings.ollama.endpoint} if you want the default).`) - -// let fullText = '' - -// const ollama = new Ollama({ host: thisConfig.endpoint }) - -// ollama.generate({ -// model: modelName, -// prompt: messages.prefix, -// suffix: messages.suffix, -// options: { -// stop: messages.stopTokens, -// num_predict: 300, // max tokens -// // repeat_penalty: 1, -// }, -// raw: true, -// stream: true, -// }) -// .then(async stream => { -// _setAborter(() => stream.abort()) -// // iterate through the stream -// for await (const chunk of stream) { -// const newText = chunk.response; -// fullText += newText; -// onText({ newText, fullText }); -// } -// onFinalMessage({ fullText, tools: [] }); -// }) -// // when error/fail -// .catch((error) => { -// onError({ message: error + '', fullError: error }) -// }) -// }; - - -// // Ollama -// export const sendOllamaChat: _InternalSendLLMChatMessageFnType = ({ messages, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter }) => { - -// const thisConfig = settingsOfProvider.ollama -// // if endpoint is empty, normally ollama will send to 11434, but we want it to fail - the user should type it in -// if (!thisConfig.endpoint) throw new Error(`Ollama Endpoint was empty (please enter ${defaultProviderSettings.ollama.endpoint} if you want the default).`) - -// let fullText = '' - -// const ollama = new Ollama({ host: thisConfig.endpoint }) - -// ollama.chat({ -// model: modelName, -// messages: messages, -// stream: true, -// // options: { num_predict: parseMaxTokensStr(thisConfig.maxTokens) } // this is max_tokens -// }) -// .then(async stream => { -// _setAborter(() => stream.abort()) -// // iterate through the stream -// for await (const chunk of stream) { -// const newText = chunk.message.content; - -// // chunk.message.tool_calls[0].function.arguments - -// fullText += newText; -// onText({ newText, fullText }); -// } - -// onFinalMessage({ fullText, tools: [] }); - -// }) -// // when error/fail -// .catch((error) => { -// onError({ message: error + '', fullError: error }) -// }) - -// }; - - - -// // ['codestral', 'qwen2.5-coder', 'qwen2.5-coder:0.5b', 'qwen2.5-coder:1.5b', 'qwen2.5-coder:3b', 'qwen2.5-coder:7b', 'qwen2.5-coder:14b', 'qwen2.5-coder:32b', 'codegemma', 'codegemma:2b', 'codegemma:7b', 'codellama', 'codellama:7b', 'codellama:13b', 'codellama:34b', 'codellama:70b', 'codellama:code', 'codellama:python', 'command-r', 'command-r:35b', 'command-r-plus', 'command-r-plus:104b', 'deepseek-coder-v2', 'deepseek-coder-v2:16b', 'deepseek-coder-v2:236b', 'falcon2', 'falcon2:11b', 'firefunction-v2', 'firefunction-v2:70b', 'gemma', 'gemma:2b', 'gemma:7b', 'gemma2', 'gemma2:2b', 'gemma2:9b', 'gemma2:27b', 'llama2', 'llama2:7b', 'llama2:13b', 'llama2:70b', 'llama3', 'llama3:8b', 'llama3:70b', 'llama3-chatqa', 'llama3-chatqa:8b', 'llama3-chatqa:70b', 'llama3-gradient', 'llama3-gradient:8b', 'llama3-gradient:70b', 'llama3.1', 'llama3.1:8b', 'llama3.1:70b', 'llama3.1:405b', 'llava', 'llava:7b', 'llava:13b', 'llava:34b', 'llava-llama3', 'llava-llama3:8b', 'llava-phi3', 'llava-phi3:3.8b', 'mistral', 'mistral:7b', 'mistral-large', 'mistral-large:123b', 'mistral-nemo', 'mistral-nemo:12b', 'mixtral', 'mixtral:8x7b', 'mixtral:8x22b', 'moondream', 'moondream:1.8b', 'openhermes', 'openhermes:v2.5', 'phi3', 'phi3:3.8b', 'phi3:14b', 'phi3.5', 'phi3.5:3.8b', 'qwen', 'qwen:7b', 'qwen:14b', 'qwen:32b', 'qwen:72b', 'qwen:110b', 'qwen2', 'qwen2:0.5b', 'qwen2:1.5b', 'qwen2:7b', 'qwen2:72b', 'smollm', 'smollm:135m', 'smollm:360m', 'smollm:1.7b',] diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/openai.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/openai.ts deleted file mode 100644 index 66c0ffe17c5..00000000000 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/openai.ts +++ /dev/null @@ -1,230 +0,0 @@ -/*-------------------------------------------------------------------------------------- - * Copyright 2025 Glass Devtools, Inc. All rights reserved. - * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. - *--------------------------------------------------------------------------------------*/ - -import OpenAI from 'openai'; -import { _InternalModelListFnType, _InternalSendLLMFIMMessageFnType, _InternalSendLLMChatMessageFnType } from '../../common/llmMessageTypes.js'; -import { Model } from 'openai/resources/models.js'; -import { InternalToolInfo } from '../../common/toolsService.js'; -import { addSystemMessageAndToolSupport } from './preprocessLLMMessages.js'; -import { developerInfoOfModelName, developerInfoOfProviderName } from '../../common/voidSettingsTypes.js'; -import { isAToolName } from './postprocessToolCalls.js'; -// import { parseMaxTokensStr } from './util.js'; - - -// developer command - https://cdn.openai.com/spec/model-spec-2024-05-08.html#follow-the-chain-of-command -// prompting - https://platform.openai.com/docs/guides/reasoning#advice-on-prompting - - -export const toOpenAITool = (toolInfo: InternalToolInfo) => { - const { name, description, params, required } = toolInfo - return { - type: 'function', - function: { - name: name, - description: description, - parameters: { - type: 'object', - properties: params, - required: required, - } - } - } satisfies OpenAI.Chat.Completions.ChatCompletionTool -} - - - - - -type NewParams = Pick[0] & Parameters<_InternalSendLLMFIMMessageFnType>[0], 'settingsOfProvider' | 'providerName'> -const newOpenAI = ({ settingsOfProvider, providerName }: NewParams) => { - - if (providerName === 'openAI') { - const thisConfig = settingsOfProvider[providerName] - return new OpenAI({ - apiKey: thisConfig.apiKey, dangerouslyAllowBrowser: true - }) - } - else if (providerName === 'ollama') { - const thisConfig = settingsOfProvider[providerName] - return new OpenAI({ - baseURL: `${thisConfig.endpoint}/v1`, apiKey: 'noop', dangerouslyAllowBrowser: true, - }) - } - else if (providerName === 'vLLM') { - const thisConfig = settingsOfProvider[providerName] - return new OpenAI({ - baseURL: `${thisConfig.endpoint}/v1`, apiKey: 'noop', dangerouslyAllowBrowser: true, - }) - } - else if (providerName === 'openRouter') { - const thisConfig = settingsOfProvider[providerName] - return new OpenAI({ - baseURL: 'https://openrouter.ai/api/v1', apiKey: thisConfig.apiKey, dangerouslyAllowBrowser: true, - defaultHeaders: { - 'HTTP-Referer': 'https://voideditor.com', // Optional, for including your app on openrouter.ai rankings. - 'X-Title': 'Void Editor', // Optional. Shows in rankings on openrouter.ai. - }, - }) - } - else if (providerName === 'gemini') { - const thisConfig = settingsOfProvider[providerName] - return new OpenAI({ - baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai', apiKey: thisConfig.apiKey, dangerouslyAllowBrowser: true, - }) - } - else if (providerName === 'deepseek') { - const thisConfig = settingsOfProvider[providerName] - return new OpenAI({ - baseURL: 'https://api.deepseek.com/v1', apiKey: thisConfig.apiKey, dangerouslyAllowBrowser: true, - }) - } - else if (providerName === 'openAICompatible') { - const thisConfig = settingsOfProvider[providerName] - return new OpenAI({ - baseURL: thisConfig.endpoint, apiKey: thisConfig.apiKey, dangerouslyAllowBrowser: true, - }) - } - else if (providerName === 'mistral') { - const thisConfig = settingsOfProvider[providerName] - return new OpenAI({ - baseURL: 'https://api.mistral.ai/v1', apiKey: thisConfig.apiKey, dangerouslyAllowBrowser: true, - }) - } - else if (providerName === 'groq') { - const thisConfig = settingsOfProvider[providerName] - return new OpenAI({ - baseURL: 'https://api.groq.com/openai/v1', apiKey: thisConfig.apiKey, dangerouslyAllowBrowser: true, - }) - } - else if (providerName === 'xAI') { - const thisConfig = settingsOfProvider[providerName] - return new OpenAI({ - baseURL: 'https://api.x.ai/v1', apiKey: thisConfig.apiKey, dangerouslyAllowBrowser: true, - }) - } - else { - console.error(`sendOpenAICompatibleMsg: invalid providerName: ${providerName}`) - throw new Error(`Void providerName was invalid: ${providerName}`) - } -} - - - -// might not currently be used in the code -export const openaiCompatibleList: _InternalModelListFnType = async ({ onSuccess: onSuccess_, onError: onError_, settingsOfProvider }) => { - const onSuccess = ({ models }: { models: Model[] }) => { - onSuccess_({ models }) - } - - const onError = ({ error }: { error: string }) => { - onError_({ error }) - } - - try { - const openai = newOpenAI({ providerName: 'openAICompatible', settingsOfProvider }) - - openai.models.list() - .then(async (response) => { - const models: Model[] = [] - models.push(...response.data) - while (response.hasNextPage()) { - models.push(...(await response.getNextPage()).data) - } - onSuccess({ models }) - }) - .catch((error) => { - onError({ error: error + '' }) - }) - } - catch (error) { - onError({ error: error + '' }) - } -} - - - - -export const sendOpenAIFIM: _InternalSendLLMFIMMessageFnType = ({ messages, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName }) => { - - - // openai.completions has a FIM parameter called `suffix`, but it's deprecated and only works for ~GPT 3 era models - - - -} - - - -// OpenAI, OpenRouter, OpenAICompatible -export const sendOpenAIChat: _InternalSendLLMChatMessageFnType = ({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName, aiInstructions, tools: tools_ }) => { - - let fullText = '' - const toolCallOfIndex: { [index: string]: { name: string, params: string, id: string } } = {} - - const { overrideSettingsForAllModels } = developerInfoOfProviderName(providerName) - const { supportsTools } = developerInfoOfModelName(modelName, overrideSettingsForAllModels) - - const { messages } = addSystemMessageAndToolSupport(modelName, providerName, messages_, aiInstructions, { separateSystemMessage: false }) - - const tools = (supportsTools && ((tools_?.length ?? 0) !== 0)) ? tools_?.map(tool => toOpenAITool(tool)) : undefined - - const openai: OpenAI = newOpenAI({ providerName, settingsOfProvider }) - const options: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { - model: modelName, - messages: messages, - stream: true, - tools: tools, - tool_choice: tools ? 'auto' : undefined, - parallel_tool_calls: tools ? false : undefined, - } - - openai.chat.completions - .create(options) - .then(async response => { - _setAborter(() => response.controller.abort()) - - // when receive text - for await (const chunk of response) { - - // tool call - for (const tool of chunk.choices[0]?.delta?.tool_calls ?? []) { - const index = tool.index - if (!toolCallOfIndex[index]) toolCallOfIndex[index] = { name: '', params: '', id: '' } - toolCallOfIndex[index].name += tool.function?.name ?? '' - toolCallOfIndex[index].params += tool.function?.arguments ?? ''; - toolCallOfIndex[index].id = tool.id ?? '' - - } - - // message - let newText = '' - newText += chunk.choices[0]?.delta?.content ?? '' - fullText += newText; - - onText({ newText, fullText }); - } - onFinalMessage({ - fullText, - toolCalls: Object.keys(toolCallOfIndex) - .map(index => { - const tool = toolCallOfIndex[index] - if (isAToolName(tool.name)) - return { name: tool.name, id: tool.id, params: tool.params } - return null - }) - .filter(t => !!t) - }); - }) - // when error/fail - this catches errors of both .create() and .then(for await) - .catch(error => { - if (error instanceof OpenAI.APIError && error.status === 401) { - onError({ message: 'Invalid API key.', fullError: error }); - } - else { - onError({ message: error + '', fullError: error }); - } - }) - -} diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/postprocessToolCalls.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/postprocessToolCalls.ts deleted file mode 100644 index 2feeeb80fdb..00000000000 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/postprocessToolCalls.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ToolName, toolNames } from '../../common/toolsService.js'; - - - -const toolNamesSet = new Set(toolNames) - -export const isAToolName = (toolName: string): toolName is ToolName => { - const isAToolName = toolNamesSet.has(toolName) - return isAToolName -} diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/preprocessLLMMessages.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/preprocessLLMMessages.ts index 689e44de7e5..32b91d07e9a 100644 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/preprocessLLMMessages.ts +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/preprocessLLMMessages.ts @@ -1,7 +1,6 @@ -import { LLMChatMessage } from '../../common/llmMessageTypes.js'; -import { developerInfoOfModelName, developerInfoOfProviderName, ProviderName } from '../../common/voidSettingsTypes.js'; +import { LLMChatMessage, LLMFIMMessage } from '../../common/llmMessageTypes.js'; import { deepClone } from '../../../../../base/common/objects.js'; @@ -14,16 +13,24 @@ export const parseObject = (args: unknown) => { return {} } -// no matter whether the model supports a system message or not (or what format it supports), add it in some way -// also take into account tools if the model doesn't support tool use -export const addSystemMessageAndToolSupport = (modelName: string, providerName: ProviderName, messages_: LLMChatMessage[], aiInstructions: string, { separateSystemMessage }: { separateSystemMessage: boolean }): { separateSystemMessageStr?: string, messages: any[] } => { +const prepareMessages_cloneAndTrim = ({ messages: messages_ }: { messages: LLMChatMessage[] }) => { const messages = deepClone(messages_).map(m => ({ ...m, content: m.content.trim(), })) + return { messages } +} - const { overrideSettingsForAllModels } = developerInfoOfProviderName(providerName) - const { supportsSystemMessage, supportsTools } = developerInfoOfModelName(modelName, overrideSettingsForAllModels) +// no matter whether the model supports a system message or not (or what format it supports), add it in some way +const prepareMessages_systemMessage = ({ + messages, + aiInstructions, + supportsSystemMessage, +}: { + messages: LLMChatMessage[], + aiInstructions: string, + supportsSystemMessage: false | 'system-role' | 'developer-role' | 'separated', +}) + : { separateSystemMessageStr?: string, messages: any[] } => { - // 1. SYSTEM MESSAGE // find system messages and concatenate them let systemMessageStr = messages .filter(msg => msg.role === 'system') @@ -33,7 +40,6 @@ export const addSystemMessageAndToolSupport = (modelName: string, providerName: if (aiInstructions) systemMessageStr = `${(systemMessageStr ? `${systemMessageStr}\n\n` : '')}GUIDELINES\n${aiInstructions}` - let separateSystemMessageStr: string | undefined = undefined // remove all system messages @@ -49,11 +55,12 @@ export const addSystemMessageAndToolSupport = (modelName: string, providerName: if (systemMessageStr) { // if supports system message if (supportsSystemMessage) { - if (separateSystemMessage) + if (supportsSystemMessage === 'separated') separateSystemMessageStr = systemMessageStr - else { - newMessages.unshift({ role: supportsSystemMessage === 'developer' ? 'developer' : 'system', content: systemMessageStr }) // add new first message - } + else if (supportsSystemMessage === 'system-role') + newMessages.unshift({ role: 'system', content: systemMessageStr }) // add new first message + else if (supportsSystemMessage === 'developer-role') + newMessages.unshift({ role: 'developer', content: systemMessageStr }) // add new first message } // if does not support system message else { @@ -79,225 +86,265 @@ export const addSystemMessageAndToolSupport = (modelName: string, providerName: } } + return { messages: newMessages, separateSystemMessageStr } +} - // 2. MAKE TOOLS FORMAT CORRECT in messages - let finalMessages: any[] - if (!supportsTools) { - // do nothing - finalMessages = newMessages - } - - // anthropic assistant message will have: https://docs.anthropic.com/en/docs/build-with-claude/tool-use#tool-use-examples - // "content": [ - // { - // "type": "text", - // "text": "I need to call the get_weather function, and the user wants SF, which is likely San Francisco, CA." - // }, - // { - // "type": "tool_use", - // "id": "toolu_01A09q90qw90lq917835lq9", - // "name": "get_weather", - // "input": { "location": "San Francisco, CA", "unit": "celsius" } - // } - // ] - - // anthropic user message response will be: - // "content": [ - // { - // "type": "tool_result", - // "tool_use_id": "toolu_01A09q90qw90lq917835lq9", - // "content": "15 degrees" - // } - // ] - - - else if (providerName === 'anthropic') { // convert role:'tool' to anthropic's type - const newMessagesTools: ( - Exclude | { - role: 'assistant', - content: string | ({ - type: 'text'; - text: string; - } | { - type: 'tool_use'; - name: string; - input: Record; - id: string; - })[] - } | { - role: 'user', - content: string | ({ - type: 'text'; - text: string; - } | { - type: 'tool_result'; - tool_use_id: string; - content: string; - })[] - } - )[] = newMessages; - +// convert messages as if about to send to openai +/* +reference - https://platform.openai.com/docs/guides/function-calling#function-calling-steps +openai MESSAGE (role=assistant): +"tool_calls":[{ + "type": "function", + "id": "call_12345xyz", + "function": { + "name": "get_weather", + "arguments": "{\"latitude\":48.8566,\"longitude\":2.3522}" +}] + +openai RESPONSE (role=user): +{ "role": "tool", + "tool_call_id": tool_call.id, + "content": str(result) } + +also see +openai on prompting - https://platform.openai.com/docs/guides/reasoning#advice-on-prompting +openai on developer system message - https://cdn.openai.com/spec/model-spec-2024-05-08.html#follow-the-chain-of-command +*/ - for (let i = 0; i < newMessagesTools.length; i += 1) { - const currMsg = newMessagesTools[i] +const prepareMessages_tools_openai = ({ messages }: { messages: LLMChatMessage[], }) => { - if (currMsg.role !== 'tool') continue + const newMessages: ( + Exclude | { + role: 'assistant', + content: string; + tool_calls?: { + type: 'function'; + id: string; + function: { + name: string; + arguments: string; + } + }[] + } | { + role: 'tool', + id: string; // old val + tool_call_id: string; // new val + content: string; + } + )[] = []; - const prevMsg = 0 <= i - 1 && i - 1 <= newMessagesTools.length ? newMessagesTools[i - 1] : undefined + for (let i = 0; i < messages.length; i += 1) { + const currMsg = messages[i] - if (prevMsg?.role === 'assistant') { - if (typeof prevMsg.content === 'string') prevMsg.content = [{ type: 'text', text: prevMsg.content }] - prevMsg.content.push({ type: 'tool_use', id: currMsg.id, name: currMsg.name, input: parseObject(currMsg.params) }) - } + if (currMsg.role !== 'tool') { + newMessages.push(currMsg) + continue + } - // turn each tool into a user message with tool results at the end - newMessagesTools[i] = { - role: 'user', - content: [ - ...[{ type: 'tool_result', tool_use_id: currMsg.id, content: currMsg.content }] as const, - ...currMsg.content ? [{ type: 'text', text: currMsg.content }] as const : [], - ] - } + // edit previous assistant message to have called the tool + const prevMsg = 0 <= i - 1 && i - 1 <= newMessages.length ? newMessages[i - 1] : undefined + if (prevMsg?.role === 'assistant') { + prevMsg.tool_calls = [{ + type: 'function', + id: currMsg.id, + function: { + name: currMsg.name, + arguments: JSON.stringify(currMsg.params) + } + }] } - finalMessages = newMessagesTools + // add the tool + newMessages.push({ + role: 'tool', + id: currMsg.id, + content: currMsg.content, + tool_call_id: currMsg.id, + }) } + return { messages: newMessages } - // openai assistant message will have: https://platform.openai.com/docs/guides/function-calling#function-calling-steps - // "tool_calls":[ - // { - // "type": "function", - // "id": "call_12345xyz", - // "function": { - // "name": "get_weather", - // "arguments": "{\"latitude\":48.8566,\"longitude\":2.3522}" - // } - // }] +} - // openai user response will be: - // { - // "role": "tool", - // "tool_call_id": tool_call.id, - // "content": str(result) - // } - // treat all other providers like openai tool message for now - else { +// convert messages as if about to send to anthropic +/* +https://docs.anthropic.com/en/docs/build-with-claude/tool-use#tool-use-examples +anthropic MESSAGE (role=assistant): +"content": [{ + "type": "text", + "text": "I need to call the get_weather function, and the user wants SF, which is likely San Francisco, CA." +}, { + "type": "tool_use", + "id": "toolu_01A09q90qw90lq917835lq9", + "name": "get_weather", + "input": { "location": "San Francisco, CA", "unit": "celsius" } +}] +anthropic RESPONSE (role=user): +"content": [{ + "type": "tool_result", + "tool_use_id": "toolu_01A09q90qw90lq917835lq9", + "content": "15 degrees" +}] +*/ - const newMessagesTools: ( - Exclude | { - role: 'assistant', - content: string; - tool_calls?: { - type: 'function'; - id: string; - function: { - name: string; - arguments: string; - } - }[] +const prepareMessages_tools_anthropic = ({ messages }: { messages: LLMChatMessage[], }) => { + const newMessages: ( + Exclude | { + role: 'assistant', + content: string | ({ + type: 'text'; + text: string; } | { - role: 'tool', - id: string; // old val - tool_call_id: string; // new val + type: 'tool_use'; + name: string; + input: Record; + id: string; + })[] + } | { + role: 'user', + content: string | ({ + type: 'text'; + text: string; + } | { + type: 'tool_result'; + tool_use_id: string; content: string; - } - )[] = []; + })[] + } + )[] = messages; - for (let i = 0; i < newMessages.length; i += 1) { - const currMsg = newMessages[i] - if (currMsg.role !== 'tool') { - newMessagesTools.push(currMsg) - continue - } + for (let i = 0; i < newMessages.length; i += 1) { + const currMsg = newMessages[i] - // edit previous assistant message to have called the tool - const prevMsg = 0 <= i - 1 && i - 1 <= newMessagesTools.length ? newMessagesTools[i - 1] : undefined - if (prevMsg?.role === 'assistant') { - prevMsg.tool_calls = [{ - type: 'function', - id: currMsg.id, - function: { - name: currMsg.name, - arguments: JSON.stringify(currMsg.params) - } - }] - } + if (currMsg.role !== 'tool') continue - // add the tool - newMessagesTools.push({ - role: 'tool', - id: currMsg.id, - content: currMsg.content, - tool_call_id: currMsg.id, - }) + const prevMsg = 0 <= i - 1 && i - 1 <= newMessages.length ? newMessages[i - 1] : undefined + + if (prevMsg?.role === 'assistant') { + if (typeof prevMsg.content === 'string') prevMsg.content = [{ type: 'text', text: prevMsg.content }] + prevMsg.content.push({ type: 'tool_use', id: currMsg.id, name: currMsg.name, input: parseObject(currMsg.params) }) + } + + // turn each tool into a user message with tool results at the end + newMessages[i] = { + role: 'user', + content: [ + ...[{ type: 'tool_result', tool_use_id: currMsg.id, content: currMsg.content }] as const, + ...currMsg.content ? [{ type: 'text', text: currMsg.content }] as const : [], + ] } - finalMessages = newMessagesTools } + return { messages: newMessages } +} + - // 3. CROP MESSAGES SO EVERYTHING FITS IN CONTEXT - // TODO!!! +const prepareMessages_tools = ({ messages, supportsTools }: { messages: LLMChatMessage[], supportsTools: false | 'anthropic-style' | 'openai-style' }) => { + if (!supportsTools) { + return { messages: messages } + } + else if (supportsTools === 'anthropic-style') { + return prepareMessages_tools_anthropic({ messages }) + } + else if (supportsTools === 'openai-style') { + return prepareMessages_tools_openai({ messages }) + } + else { + throw 1 + } +} + - console.log('SYSMG', separateSystemMessage) - console.log('FINAL MESSAGES', JSON.stringify(finalMessages, null, 2)) - return { - separateSystemMessageStr, - messages: finalMessages, +/* +Gemini has this, but they're openai-compat so we don't need to implement this +gemini request: +{ "role": "assistant", + "content": null, + "function_call": { + "name": "get_weather", + "arguments": { + "latitude": 48.8566, + "longitude": 2.3522 + } + } +} + +gemini response: +{ "role": "assistant", + "function_response": { + "name": "get_weather", + "response": { + "temperature": "15°C", + "condition": "Cloudy" + } } } +*/ -/* -ACCORDING TO 4o: gemini: similar to openai, but function_call, and only 1 call per message (no id because only 1 message) -gemini request: { -"role": "assistant", -"content": null, -"function_call": { -"name": "get_weather", -"arguments": { -"latitude": 48.8566, -"longitude": 2.3522 -} -} -} -gemini response: -{ -"role": "assistant", -"function_response": { -"name": "get_weather", -"response": { -"temperature": "15°C", -"condition": "Cloudy" -} -} + + + +export const prepareMessages = ({ + messages, + aiInstructions, + supportsSystemMessage, + supportsTools, +}: { + messages: LLMChatMessage[], + aiInstructions: string, + supportsSystemMessage: false | 'system-role' | 'developer-role' | 'separated', + supportsTools: false | 'anthropic-style' | 'openai-style', +}) => { + const { messages: messages1 } = prepareMessages_cloneAndTrim({ messages }) + const { messages: messages2, separateSystemMessageStr } = prepareMessages_systemMessage({ messages: messages1, aiInstructions, supportsSystemMessage }) + const { messages: messages3 } = prepareMessages_tools({ messages: messages2, supportsTools }) + return { + messages: messages3 as any, + separateSystemMessageStr + } as const } -+ anthropic -+ openai-compat (4) -+ gemini -ollama +export const prepareFIMMessage = ({ + messages, + aiInstructions, +}: { + messages: LLMFIMMessage, + aiInstructions: string, +}) => { -mistral: same as openai + let prefix = `\ +${!aiInstructions ? '' : `\ +// Instructions: +// Do not output an explanation. Try to avoid outputting comments. Only output the middle code. +${aiInstructions.split('\n').map(line => `//${line}`).join('\n')}`} -*/ +${messages.prefix}` + + const suffix = messages.suffix + const stopTokens = messages.stopTokens + const ret = { prefix, suffix, stopTokens, maxTokens: 300 } as const + console.log('ret', ret) + return ret +} diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.ts index 8e29bff49a0..90deffe2a30 100644 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.ts +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.ts @@ -6,9 +6,7 @@ import { SendLLMMessageParams, OnText, OnFinalMessage, OnError } from '../../common/llmMessageTypes.js'; import { IMetricsService } from '../../common/metricsService.js'; import { displayInfoOfProviderName } from '../../common/voidSettingsTypes.js'; - -import { sendAnthropicChat } from './anthropic.js'; -import { sendOpenAIChat } from './openai.js'; +import { sendLLMMessageToProviderImplementation } from './MODELS.js'; export const sendLLMMessage = ({ @@ -35,6 +33,8 @@ export const sendLLMMessage = ({ metricsService.capture(eventId, { providerName, modelName, + customEndpointURL: settingsOfProvider[providerName]?.endpoint, + numModelsAtEndpoint: settingsOfProvider[providerName]?.models?.length, ...messagesType === 'chatMessages' ? { numMessages: messages_?.length, messagesShape: messages_?.map(msg => ({ role: msg.role, length: msg.content.length })), @@ -56,9 +56,10 @@ export const sendLLMMessage = ({ let _setAborter = (fn: () => void) => { _aborter = fn } let _didAbort = false - const onText: OnText = ({ newText, fullText }) => { + const onText: OnText = (params) => { + const { fullText } = params if (_didAbort) return - onText_({ newText, fullText }) + onText_(params) _fullTextSoFar = fullText } @@ -74,7 +75,7 @@ export const sendLLMMessage = ({ // handle failed to fetch errors, which give 0 information by design if (error === 'TypeError: fetch failed') - error = `Failed to fetch from ${displayInfoOfProviderName(providerName).title}. This likely means you specified the wrong endpoint in Void Settings, or your local model provider like Ollama is powered off.` + error = `Failed to fetch from ${displayInfoOfProviderName(providerName).title}. This likely means you specified the wrong endpoint in Void's Settings, or your local model provider like Ollama is powered off.` captureLLMEvent(`${loggingName} - Error`, { error }) onError_({ message: error, fullError }) @@ -93,29 +94,27 @@ export const sendLLMMessage = ({ else if (messagesType === 'FIMMessage') captureLLMEvent(`${loggingName} - Sending FIM`, {}) // TODO!!! add more metrics + try { - switch (providerName) { - case 'openAI': - case 'openRouter': - case 'deepseek': - case 'openAICompatible': - case 'mistral': - case 'ollama': - case 'vLLM': - case 'groq': - case 'gemini': - case 'xAI': - if (messagesType === 'FIMMessage') onFinalMessage({ fullText: 'TODO - OpenAI API FIM', toolCalls: [] }) - else /* */ sendOpenAIChat({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName, aiInstructions, tools }); - break; - case 'anthropic': - if (messagesType === 'FIMMessage') onFinalMessage({ fullText: 'TODO - Anthropic FIM', toolCalls: [] }) - else /* */ sendAnthropicChat({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName, aiInstructions, tools }); - break; - default: - onError({ message: `Error: Void provider was "${providerName}", which is not recognized.`, fullError: null }) - break; + const implementation = sendLLMMessageToProviderImplementation[providerName] + if (!implementation) { + onError({ message: `Error: Provider "${providerName}" not recognized.`, fullError: null }) + return + } + const { sendFIM, sendChat } = implementation + if (messagesType === 'chatMessages') { + sendChat({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName, aiInstructions, tools }) + return + } + if (messagesType === 'FIMMessage') { + if (sendFIM) { + sendFIM({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName, aiInstructions }) + return + } + onError({ message: `Error: This provider does not support Autocomplete yet.`, fullError: null }) + return } + onError({ message: `Error: Message type "${messagesType}" not recognized.`, fullError: null }) } catch (error) { diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessageChannel.ts b/src/vs/workbench/contrib/void/electron-main/llmMessageChannel.ts index b00ade9cd2f..d2bceb4c06e 100644 --- a/src/vs/workbench/contrib/void/electron-main/llmMessageChannel.ts +++ b/src/vs/workbench/contrib/void/electron-main/llmMessageChannel.ts @@ -8,30 +8,42 @@ import { IServerChannel } from '../../../../base/parts/ipc/common/ipc.js'; import { Emitter, Event } from '../../../../base/common/event.js'; -import { EventLLMMessageOnTextParams, EventLLMMessageOnErrorParams, EventLLMMessageOnFinalMessageParams, MainSendLLMMessageParams, AbortRef, SendLLMMessageParams, MainLLMMessageAbortParams, MainModelListParams, ModelListParams, EventModelListOnSuccessParams, EventModelListOnErrorParams, OllamaModelResponse, OpenaiCompatibleModelResponse, } from '../common/llmMessageTypes.js'; +import { EventLLMMessageOnTextParams, EventLLMMessageOnErrorParams, EventLLMMessageOnFinalMessageParams, MainSendLLMMessageParams, AbortRef, SendLLMMessageParams, MainLLMMessageAbortParams, ModelListParams, EventModelListOnSuccessParams, EventModelListOnErrorParams, OllamaModelResponse, VLLMModelResponse, MainModelListParams, } from '../common/llmMessageTypes.js'; import { sendLLMMessage } from './llmMessage/sendLLMMessage.js' import { IMetricsService } from '../common/metricsService.js'; -import { ollamaList } from './llmMessage/ollama.js'; -import { openaiCompatibleList } from './llmMessage/openai.js'; +import { sendLLMMessageToProviderImplementation } from './llmMessage/MODELS.js'; // NODE IMPLEMENTATION - calls actual sendLLMMessage() and returns listeners to it export class LLMMessageChannel implements IServerChannel { + // sendLLMMessage - private readonly _onText_llm = new Emitter(); - private readonly _onFinalMessage_llm = new Emitter(); - private readonly _onError_llm = new Emitter(); + private readonly llmMessageEmitters = { + onText: new Emitter(), + onFinalMessage: new Emitter(), + onError: new Emitter(), + } - // abort - private readonly _abortRefOfRequestId_llm: Record = {} + // aborters for above + private readonly abortRefOfRequestId: Record = {} - // ollamaList - private readonly _onSuccess_ollama = new Emitter>(); - private readonly _onError_ollama = new Emitter>(); - // openaiCompatibleList - private readonly _onSuccess_openAICompatible = new Emitter>(); - private readonly _onError_openAICompatible = new Emitter>(); + // list + private readonly listEmitters = { + ollama: { + success: new Emitter>(), + error: new Emitter>(), + }, + vLLM: { + success: new Emitter>(), + error: new Emitter>(), + } + } satisfies { + [providerName: string]: { + success: Emitter>, + error: Emitter>, + } + } // stupidly, channels can't take in @IService constructor( @@ -40,30 +52,17 @@ export class LLMMessageChannel implements IServerChannel { // browser uses this to listen for changes listen(_: unknown, event: string): Event { - if (event === 'onText_llm') { - return this._onText_llm.event; - } - else if (event === 'onFinalMessage_llm') { - return this._onFinalMessage_llm.event; - } - else if (event === 'onError_llm') { - return this._onError_llm.event; - } - else if (event === 'onSuccess_ollama') { - return this._onSuccess_ollama.event; - } - else if (event === 'onError_ollama') { - return this._onError_ollama.event; - } - else if (event === 'onSuccess_openAICompatible') { - return this._onSuccess_openAICompatible.event; - } - else if (event === 'onError_openAICompatible') { - return this._onError_openAICompatible.event; - } - else { - throw new Error(`Event not found: ${event}`); - } + // text + if (event === 'onText_sendLLMMessage') return this.llmMessageEmitters.onText.event; + else if (event === 'onFinalMessage_sendLLMMessage') return this.llmMessageEmitters.onFinalMessage.event; + else if (event === 'onError_sendLLMMessage') return this.llmMessageEmitters.onError.event; + // list + else if (event === 'onSuccess_list_ollama') return this.listEmitters.ollama.success.event; + else if (event === 'onError_list_ollama') return this.listEmitters.ollama.error.event; + else if (event === 'onSuccess_list_vLLM') return this.listEmitters.vLLM.success.event; + else if (event === 'onError_list_vLLM') return this.listEmitters.vLLM.error.event; + + else throw new Error(`Event not found: ${event}`); } // browser uses this to call (see this.channel.call() in llmMessageService.ts for all usages) @@ -78,8 +77,8 @@ export class LLMMessageChannel implements IServerChannel { else if (command === 'ollamaList') { this._callOllamaList(params) } - else if (command === 'openAICompatibleList') { - this._callOpenAICompatibleList(params) + else if (command === 'vLLMList') { + this._callVLLMList(params) } else { throw new Error(`Void sendLLM: command "${command}" not recognized.`) @@ -94,47 +93,50 @@ export class LLMMessageChannel implements IServerChannel { private async _callSendLLMMessage(params: MainSendLLMMessageParams) { const { requestId } = params; - if (!(requestId in this._abortRefOfRequestId_llm)) - this._abortRefOfRequestId_llm[requestId] = { current: null } + if (!(requestId in this.abortRefOfRequestId)) + this.abortRefOfRequestId[requestId] = { current: null } const mainThreadParams: SendLLMMessageParams = { ...params, - onText: ({ newText, fullText }) => { this._onText_llm.fire({ requestId, newText, fullText }); }, - onFinalMessage: ({ fullText, toolCalls }) => { this._onFinalMessage_llm.fire({ requestId, fullText, toolCalls }); }, - onError: ({ message: error, fullError }) => { console.log('sendLLM: firing err'); this._onError_llm.fire({ requestId, message: error, fullError }); }, - abortRef: this._abortRefOfRequestId_llm[requestId], + onText: (p) => { this.llmMessageEmitters.onText.fire({ requestId, ...p }); }, + onFinalMessage: (p) => { this.llmMessageEmitters.onFinalMessage.fire({ requestId, ...p }); }, + onError: (p) => { console.log('sendLLM: firing err'); this.llmMessageEmitters.onError.fire({ requestId, ...p }); }, + abortRef: this.abortRefOfRequestId[requestId], } sendLLMMessage(mainThreadParams, this.metricsService); } - private _callAbort(params: MainLLMMessageAbortParams) { - const { requestId } = params; - if (!(requestId in this._abortRefOfRequestId_llm)) return - this._abortRefOfRequestId_llm[requestId].current?.() - delete this._abortRefOfRequestId_llm[requestId] - } - - private _callOllamaList(params: MainModelListParams) { - const { requestId } = params; - + _callOllamaList = (params: MainModelListParams) => { + const { requestId } = params + const emitters = this.listEmitters.ollama const mainThreadParams: ModelListParams = { ...params, - onSuccess: ({ models }) => { this._onSuccess_ollama.fire({ requestId, models }); }, - onError: ({ error }) => { this._onError_ollama.fire({ requestId, error }); }, + onSuccess: (p) => { emitters.success.fire({ requestId, ...p }); }, + onError: (p) => { emitters.error.fire({ requestId, ...p }); }, } - ollamaList(mainThreadParams) + sendLLMMessageToProviderImplementation.ollama.list(mainThreadParams) } - private _callOpenAICompatibleList(params: MainModelListParams) { - const { requestId } = params; - - const mainThreadParams: ModelListParams = { + _callVLLMList = (params: MainModelListParams) => { + const { requestId } = params + const emitters = this.listEmitters.vLLM + const mainThreadParams: ModelListParams = { ...params, - onSuccess: ({ models }) => { this._onSuccess_openAICompatible.fire({ requestId, models }); }, - onError: ({ error }) => { this._onError_openAICompatible.fire({ requestId, error }); }, + onSuccess: (p) => { emitters.success.fire({ requestId, ...p }); }, + onError: (p) => { emitters.error.fire({ requestId, ...p }); }, } - openaiCompatibleList(mainThreadParams) + sendLLMMessageToProviderImplementation.vLLM.list(mainThreadParams) } + + + + private _callAbort(params: MainLLMMessageAbortParams) { + const { requestId } = params; + if (!(requestId in this.abortRefOfRequestId)) return + this.abortRefOfRequestId[requestId].current?.() + delete this.abortRefOfRequestId[requestId] + } + }