main thread streaming state

This commit is contained in:
Andrew 2024-11-03 22:37:23 -08:00
parent 6c93028ce3
commit 4ffdf546f0
5 changed files with 60 additions and 419 deletions

View file

@ -1,365 +0,0 @@
// import Anthropic from '@anthropic-ai/sdk';
// import OpenAI from 'openai';
// import { Ollama } from 'ollama'
// import { Content, GoogleGenerativeAI, GoogleGenerativeAIError, GoogleGenerativeAIFetchError } from '@google/generative-ai';
// import { VoidConfig } from '../webviews/common/contextForConfig'
// export type AbortRef = { current: (() => void) | null }
// export type OnText = (newText: string, fullText: string) => void
// export type OnFinalMessage = (input: string) => void
// export type LLMMessageAnthropic = {
// role: 'user' | 'assistant';
// content: string;
// }
// export type LLMMessage = {
// role: 'system' | 'user' | 'assistant';
// content: string;
// }
// type SendLLMMessageFnTypeInternal = (params: {
// messages: LLMMessage[];
// onText: OnText;
// onFinalMessage: OnFinalMessage;
// onError: (error: string) => void;
// voidConfig: VoidConfig;
// abortRef: AbortRef;
// }) => void
// type SendLLMMessageFnTypeExternal = (params: {
// messages: LLMMessage[];
// onText: OnText;
// onFinalMessage: (fullText: string) => void;
// onError: (error: string) => void;
// voidConfig: VoidConfig | null;
// abortRef: AbortRef;
// }) => void
// 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
// }
// // Anthropic
// const sendAnthropicMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinalMessage, onError, voidConfig }) => {
// const anthropic = new Anthropic({ apiKey: voidConfig.anthropic.apikey, dangerouslyAllowBrowser: true }); // defaults to process.env["ANTHROPIC_API_KEY"]
// // find system messages and concatenate them
// const systemMessage = messages
// .filter(msg => msg.role === 'system')
// .map(msg => msg.content)
// .join('\n');
// // remove system messages for Anthropic
// const anthropicMessages = messages.filter(msg => msg.role !== 'system') as LLMMessageAnthropic[]
// const stream = anthropic.messages.stream({
// system: systemMessage,
// messages: anthropicMessages,
// model: voidConfig.anthropic.model,
// max_tokens: parseMaxTokensStr(voidConfig.default.maxTokens)!, // this might be undefined, but it will just throw an error for the user
// });
// let did_abort = false
// // when receive text
// stream.on('text', (newText, fullText) => {
// if (did_abort) return
// onText(newText, fullText)
// })
// // when we get the final message on this stream (or when error/fail)
// stream.on('finalMessage', (claude_response) => {
// if (did_abort) return
// // stringify the response's content
// const content = claude_response.content.map(c => c.type === 'text' ? c.text : c.type).join('\n');
// onFinalMessage(content)
// })
// 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('Invalid API key.')
// }
// else {
// onError(error.message)
// }
// })
// // if abort is called, onFinalMessage is NOT called, and no later onTexts are called either
// const abort = () => {
// did_abort = true
// stream.controller.abort() // TODO need to test this to make sure it works, it might throw an error
// }
// return { abort }
// };
// // Gemini
// const sendGeminiMsg: SendLLMMessageFnTypeInternal = async ({ messages, onText, onFinalMessage, onError, voidConfig, abortRef }) => {
// let didAbort = false
// let fullText = ''
// abortRef.current = () => {
// didAbort = true
// }
// const genAI = new GoogleGenerativeAI(voidConfig.gemini.apikey);
// const model = genAI.getGenerativeModel({ model: voidConfig.gemini.model });
// // remove system messages that get sent to Gemini
// // str of all system messages
// const systemMessage = messages
// .filter(msg => msg.role === 'system')
// .map(msg => msg.content)
// .join('\n');
// // Convert messages to Gemini format
// const geminiMessages: Content[] = messages
// .filter(msg => msg.role !== 'system')
// .map((msg, i) => ({
// parts: [{ text: msg.content }],
// role: msg.role === 'assistant' ? 'model' : 'user'
// }))
// model.generateContentStream({ contents: geminiMessages, systemInstruction: systemMessage, })
// .then(async response => {
// abortRef.current = () => {
// // response.stream.return(fullText)
// didAbort = true;
// }
// for await (const chunk of response.stream) {
// if (didAbort) return;
// const newText = chunk.text();
// fullText += newText;
// onText(newText, fullText);
// }
// onFinalMessage(fullText);
// })
// .catch((error) => {
// if (error instanceof GoogleGenerativeAIFetchError) {
// if (error.status === 400) {
// onError('Invalid API key.');
// }
// else {
// onError(`${error.name}:\n${error.message}`);
// }
// }
// else {
// onError(error);
// }
// })
// }
// // OpenAI, OpenRouter, OpenAICompatible
// const sendOpenAIMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinalMessage, onError, voidConfig, abortRef }) => {
// let didAbort = false
// let fullText = ''
// // if abort is called, onFinalMessage is NOT called, and no later onTexts are called either
// abortRef.current = () => {
// didAbort = true;
// };
// let openai: OpenAI
// let options: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming
// const maxTokens = parseMaxTokensStr(voidConfig.default.maxTokens)
// if (voidConfig.default.whichApi === 'openAI') {
// openai = new OpenAI({ apiKey: voidConfig.openAI.apikey, dangerouslyAllowBrowser: true });
// options = { model: voidConfig.openAI.model, messages: messages, stream: true, max_completion_tokens: maxTokens }
// }
// else if (voidConfig.default.whichApi === 'openRouter') {
// openai = new OpenAI({
// baseURL: 'https://openrouter.ai/api/v1', apiKey: voidConfig.openRouter.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.
// },
// });
// options = { model: voidConfig.openRouter.model, messages: messages, stream: true, max_completion_tokens: maxTokens }
// }
// else if (voidConfig.default.whichApi === 'openAICompatible') {
// openai = new OpenAI({ baseURL: voidConfig.openAICompatible.endpoint, apiKey: voidConfig.openAICompatible.apikey, dangerouslyAllowBrowser: true })
// options = { model: voidConfig.openAICompatible.model, messages: messages, stream: true, max_completion_tokens: maxTokens }
// }
// else {
// console.error(`sendOpenAIMsg: invalid whichApi: ${voidConfig.default.whichApi}`)
// throw new Error(`voidConfig.whichAPI was invalid: ${voidConfig.default.whichApi}`)
// }
// openai.chat.completions
// .create(options)
// .then(async response => {
// abortRef.current = () => {
// // response.controller.abort()
// didAbort = true;
// }
// // when receive text
// for await (const chunk of response) {
// if (didAbort) return;
// const newText = chunk.choices[0]?.delta?.content || '';
// fullText += newText;
// onText(newText, fullText);
// }
// onFinalMessage(fullText);
// })
// // when error/fail - this catches errors of both .create() and .then(for await)
// .catch(error => {
// if (error instanceof OpenAI.APIError) {
// if (error.status === 401) {
// onError('Invalid API key.');
// }
// else {
// onError(`${error.name}:\n${error.message}`);
// }
// }
// else {
// onError(error);
// }
// })
// };
// // Ollama
// export const sendOllamaMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinalMessage, onError, voidConfig, abortRef }) => {
// let didAbort = false
// let fullText = ''
// // if abort is called, onFinalMessage is NOT called, and no later onTexts are called either
// abortRef.current = () => {
// didAbort = true;
// };
// const ollama = new Ollama({ host: voidConfig.ollama.endpoint })
// ollama.chat({
// model: voidConfig.ollama.model,
// messages: messages,
// stream: true,
// options: { num_predict: parseMaxTokensStr(voidConfig.default.maxTokens) } // this is max_tokens
// })
// .then(async stream => {
// abortRef.current = () => {
// // stream.abort()
// didAbort = true
// }
// // iterate through the stream
// for await (const chunk of stream) {
// if (didAbort) return;
// const newText = chunk.message.content;
// fullText += newText;
// onText(newText, fullText);
// }
// onFinalMessage(fullText);
// })
// // when error/fail
// .catch(error => {
// onError(error)
// })
// };
// // Greptile
// // https://docs.greptile.com/api-reference/query
// // https://docs.greptile.com/quickstart#sample-response-streamed
// const sendGreptileMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinalMessage, onError, voidConfig, abortRef }) => {
// let didAbort = false
// let fullText = ''
// // if abort is called, onFinalMessage is NOT called, and no later onTexts are called either
// abortRef.current = () => {
// didAbort = true
// }
// fetch('https://api.greptile.com/v2/query', {
// method: 'POST',
// headers: {
// 'Authorization': `Bearer ${voidConfig.greptile.apikey}`,
// 'X-Github-Token': `${voidConfig.greptile.githubPAT}`,
// 'Content-Type': `application/json`,
// },
// body: JSON.stringify({
// messages,
// stream: true,
// repositories: [voidConfig.greptile.repoinfo],
// }),
// })
// // this is {message}\n{message}\n{message}...\n
// .then(async response => {
// const text = await response.text()
// console.log('got greptile', text)
// return JSON.parse(`[${text.trim().split('\n').join(',')}]`)
// })
// // TODO make this actually stream, right now it just sends one message at the end
// .then(async responseArr => {
// if (didAbort)
// return
// for (const response of responseArr) {
// const type: string = response['type']
// const message = response['message']
// // when receive text
// if (type === 'message') {
// fullText += message
// onText(message, fullText)
// }
// else if (type === 'sources') {
// const { filepath, linestart: _, lineend: _2 } = message as { filepath: string; linestart: number | null; lineend: number | null }
// fullText += filepath
// onText(filepath, fullText)
// }
// // type: 'status' with an empty 'message' means last message
// else if (type === 'status') {
// if (!message) {
// onFinalMessage(fullText)
// }
// }
// }
// })
// .catch(e => {
// onError(e)
// });
// }
// export const sendLLMMessage: SendLLMMessageFnTypeExternal = ({ messages, onText, onFinalMessage, onError, voidConfig, abortRef }) => {
// if (!voidConfig) return;
// // trim message content (Anthropic and other providers give an error if there is trailing whitespace)
// messages = messages.map(m => ({ ...m, content: m.content.trim() }))
// switch (voidConfig.default.whichApi) {
// case 'anthropic':
// return sendAnthropicMsg({ messages, onText, onFinalMessage, onError, voidConfig, abortRef });
// case 'openAI':
// case 'openRouter':
// case 'openAICompatible':
// return sendOpenAIMsg({ messages, onText, onFinalMessage, onError, voidConfig, abortRef });
// case 'gemini':
// return sendGeminiMsg({ messages, onText, onFinalMessage, onError, voidConfig, abortRef });
// case 'ollama':
// return sendOllamaMsg({ messages, onText, onFinalMessage, onError, voidConfig, abortRef });
// case 'greptile':
// return sendGreptileMsg({ messages, onText, onFinalMessage, onError, voidConfig, abortRef });
// default:
// onError(`Error: whichApi was ${voidConfig.default.whichApi}, which is not recognized!`)
// }
// }

View file

@ -9,7 +9,8 @@ import { ICodeEditor } from '../../../editor/browser/editorBrowser.js';
import { IRange } from '../../../editor/common/core/range.js';
import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js';
import { IUndoRedoElement, IUndoRedoService, UndoRedoElementType, UndoRedoGroup } from '../../../platform/undoRedo/common/undoRedo.js';
import { URI } from '../../../base/common/uri.js';
import { IBulkEditService } from '../../../editor/browser/services/bulkEditService.js';
import { WorkspaceEdit } from '../../../editor/common/languages.js';
// import { IHistoryService } from '../../services/history/common/history.js';
@ -26,6 +27,7 @@ export class MainThreadInlineDiff extends Disposable implements MainThreadInline
@ICodeEditorService private readonly _editorService: ICodeEditorService,
// @IHistoryService private readonly _historyService: IHistoryService, // history service is the history of pressing alt left/right
@IUndoRedoService private readonly _undoRedoService: IUndoRedoService, // undoRedo service is the history of pressing ctrl+z
@IBulkEditService private readonly _bulkEditService: IBulkEditService,
) {
super();
@ -34,72 +36,69 @@ export class MainThreadInlineDiff extends Disposable implements MainThreadInline
// this._wcHistoryService.addEntry()
}
_streamingState: 'streaming' | 'idle' = 'idle'
startStreaming(editor: ICodeEditor) {
this._streamingState = 'streaming'
// count all changes towards the group
// const versionId = editor.getModel()?.getVersionId()
this._register(editor.onDidChangeModelContent((e) => {
// user presses undo (and there is something to undo)
if (e.isUndoing) {
// cancel the stream, then undo normally
return
}
// user presses redo (and there is something to redo)
if (e.isRedoing) {
// cancel the stream, then redo normally
return
}
// for good measure
if (e.isEolChange) {
// cancel stream and apply change normally
return
}
// ignore any other kind of change (make it not happen)
if (this._streamingState === 'streaming') {
// completely ignore the change
return
}
}));
// streamChange(){
// }
_streamingState: { type: 'streaming'; editGroup: UndoRedoGroup } | { type: 'idle' } = { type: 'idle' }
startStreaming(editorId: string) {
const editor = this._getEditor(editorId)
if (!editor) return
const model = editor.getModel()
if (!model) return
// all changes made when streaming should be a part of the group so we can undo them all together
const group = new UndoRedoGroup()
this._streamingState = {
type: 'streaming',
editGroup: new UndoRedoGroup()
}
// TODO probably need to convert this to a stack
const diffsSnapshotBefore = { placeholder: '' }
const diffsSnapshotAfter = { placeholder: '' }
const elt: IUndoRedoElement = {
type: UndoRedoElementType.Resource,
resource: URI.parse('file:///path/to/file.txt'),
resource: model.uri,
label: 'Add Diffs',
code: 'undoredo.inlineDiff',
undo: () => {
// reapply diffareas and diffs here
console.log('reverting diffareas...', diffsSnapshotBefore.placeholder)
},
redo: () => {
// reapply diffareas and diffs here
// when done, need to record diffSnapshotAfter
console.log('re-applying diffareas...', diffsSnapshotAfter.placeholder)
}
}
this._undoRedoService.pushElement(elt, group)
this._undoRedoService.pushElement(elt, this._streamingState.editGroup)
// ---------- START ----------
editor.updateOptions({ readOnly: true })
// ---------- WHEN DONE ----------
editor.updateOptions({ readOnly: false })
}
streamChange(editorId: string, edit: WorkspaceEdit) {
const editor = this._getEditor(editorId)
if (!editor) return
if (this._streamingState.type !== 'streaming') {
console.error('Expected streamChange to be in state \'streaming\'.')
return
}
// count all changes towards the group
this._bulkEditService.apply(edit, { undoRedoGroupId: this._streamingState.editGroup.id, })
}

View file

@ -3,16 +3,16 @@ import {
Extensions as ViewContainerExtensions, IViewContainersRegistry,
ViewContainerLocation, IViewsRegistry, Extensions as ViewExtensions,
IViewDescriptor
} from '../../../../workbench/common/views.js';
} from '../../../common/views.js';
import * as nls from '../../../../nls.js';
import { VoidViewPane } from '../../../../workbench/contrib/void/browser/voidViewPane.js';
import { VoidViewPane } from './voidViewPane.js';
import { Codicon } from '../../../../base/common/codicons.js';
import { localize } from '../../../../nls.js';
import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js';
import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js';
import { ViewPaneContainer } from '../../../browser/parts/views/viewPaneContainer.js';
import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js';
import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js';

View file

@ -1,7 +1,7 @@
import { ViewPane } from '../../../../workbench/browser/parts/views/viewPane.js';
import { ViewPane } from '../../../browser/parts/views/viewPane.js';
// import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
// import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey';

View file

@ -12,6 +12,13 @@ import './browser/workbench.contribution.js';
//#endregion
//#region --- void
// Void added this:
import './contrib/void/browser/void.contribution.js';
//#endregion
//#region --- workbench actions