From 471333ec16324afeb2318b05f1ae2cb7bc35c142 Mon Sep 17 00:00:00 2001 From: Andrew Pareles Date: Wed, 15 Jan 2025 18:21:43 -0800 Subject: [PATCH] can have many streams at once! --- .../contrib/void/browser/chatThreadService.ts | 302 ++++++++++++++++++ .../contrib/void/browser/helpers/readFile.ts | 10 + .../contrib/void/browser/prompt/prompts.ts | 2 +- .../src/quick-edit-tsx/QuickEditChat.tsx | 2 +- .../react/src/sidebar-tsx/SidebarChat.tsx | 147 ++------- .../src/sidebar-tsx/SidebarThreadSelector.tsx | 11 +- .../void/browser/react/src/util/services.tsx | 63 +++- .../contrib/void/browser/sidebarActions.ts | 14 +- .../void/browser/threadHistoryService.ts | 219 ------------- .../contrib/void/browser/void.contribution.ts | 2 +- 10 files changed, 403 insertions(+), 369 deletions(-) create mode 100644 src/vs/workbench/contrib/void/browser/chatThreadService.ts create mode 100644 src/vs/workbench/contrib/void/browser/helpers/readFile.ts delete mode 100644 src/vs/workbench/contrib/void/browser/threadHistoryService.ts diff --git a/src/vs/workbench/contrib/void/browser/chatThreadService.ts b/src/vs/workbench/contrib/void/browser/chatThreadService.ts new file mode 100644 index 00000000..0ea193be --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/chatThreadService.ts @@ -0,0 +1,302 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; + +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 '../../../../platform/void/common/llmMessageService.js'; +import { IModelService } from '../../../../editor/common/services/model.js'; +import { VSReadFile } from './helpers/readFile.js'; +import { chat_prompt, chat_systemMessage } from './prompt/prompts.js'; + +export type CodeSelection = { + fileURI: URI; + selectionStr: string | null; + content: string; // TODO remove this (replace `selectionStr` with `content`) + range: IRange; +} + +// if selectionStr is null, it means to use the entire file at send time +export type CodeStagingSelection = { + type: 'Selection', + fileURI: URI, + selectionStr: string, + range: IRange +} | { + type: 'File', + fileURI: URI, + selectionStr: null, + range: null +} + + +// WARNING: changing this format is a big deal!!!!!! need to migrate old format to new format on users' computers so people don't get errors. +export type ChatMessage = + | { + role: 'user'; + content: string | null; // content sent to the llm - allowed to be '', will be replaced with (empty) + displayContent: string | null; // content displayed to user - allowed to be '', will be ignored + selections: CodeSelection[] | null; // the user's selection + } + | { + 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 + } + | { + role: 'system'; + content: string; + displayContent?: undefined; + } + +// a 'thread' means a chat message history +export type ChatThreads = { + [id: string]: { + id: string; // store the id here too + createdAt: string; // ISO string + lastModified: string; // ISO string + messages: ChatMessage[]; + }; +} + +export type ThreadsState = { + allThreads: ChatThreads; + currentThreadId: string; // intended for internal use only + currentStagingSelections: CodeStagingSelection[] | null; +} + +export type ThreadStreamState = { + [threadId: string]: undefined | { + streamingToken?: string; + error?: { message: string, fullError: Error | null }; + messageSoFar?: string; + } +} + + +const newThreadObject = () => { + const now = new Date().toISOString() + return { + id: new Date().getTime().toString(), + createdAt: now, + lastModified: now, + messages: [], + } satisfies ChatThreads[string] +} + +const THREAD_STORAGE_KEY = 'void.chatThreadStorage' + +export interface IChatThreadService { + readonly _serviceBrand: undefined; + + readonly state: ThreadsState; + readonly streamState: ThreadStreamState; + + onDidChangeCurrentThread: Event; + onDidChangeStreamState: Event<{ threadId: string }> + + getCurrentThread(): ChatThreads[string]; + openNewThread(): void; + switchToThread(threadId: string): void; + + setStaging(stagingSelection: CodeStagingSelection[] | null): void; + + addUserMessageAndStreamResponse(userMessage: string): Promise; + cancelStreaming(threadId: string): void; + dismissStreamError(threadId: string): void; + +} + +export const IChatThreadService = createDecorator('voidChatThreadService'); +class ChatThreadService extends Disposable implements IChatThreadService { + _serviceBrand: undefined; + + // this fires when the current thread changes at all (a switch of currentThread, or a message added to it, etc) + private readonly _onDidChangeCurrentThread = new Emitter(); + readonly onDidChangeCurrentThread: Event = this._onDidChangeCurrentThread.event; + + readonly streamState: ThreadStreamState = {} + private readonly _onDidChangeStreamState = new Emitter<{ threadId: string }>(); + readonly onDidChangeStreamState: Event<{ threadId: string }> = this._onDidChangeStreamState.event; + + state: ThreadsState // allThreads is persisted, currentThread is not + + constructor( + @IStorageService private readonly _storageService: IStorageService, + @IModelService private readonly _modelService: IModelService, + @ILLMMessageService private readonly _llmMessageService: ILLMMessageService, + ) { + super() + + this.state = { + allThreads: this._readAllThreads(), + currentThreadId: null as unknown as string, // gets set in startNewThread() + currentStagingSelections: null, + } + + // always be in a thread + this.openNewThread() + } + + + private _readAllThreads(): ChatThreads { + // PUT ANY VERSION CHANGE FORMAT CONVERSION CODE HERE + const threads = this._storageService.get(THREAD_STORAGE_KEY, StorageScope.APPLICATION) + return threads ? JSON.parse(threads) : {} + } + + private _storeAllThreads(threads: ChatThreads) { + this._storageService.store(THREAD_STORAGE_KEY, JSON.stringify(threads), StorageScope.APPLICATION, StorageTarget.USER) + } + + // this should be the only place this.state = ... appears besides constructor + private _setState(state: Partial, affectsCurrent: boolean) { + this.state = { + ...this.state, + ...state + } + if (affectsCurrent) + this._onDidChangeCurrentThread.fire() + } + + private _setStreamState(threadId: string, state: Partial>) { + this.streamState[threadId] = { + ...this.streamState[threadId], + ...state + } + this._onDidChangeStreamState.fire({ threadId }) + } + + + // ---------- streaming ---------- + + async addUserMessageAndStreamResponse(userMessage: string) { + const threadId = this.getCurrentThread().id + + const currSelns = this.state.currentStagingSelections ?? [] + const selections = !currSelns ? null : await Promise.all( + currSelns.map(async (sel) => ({ ...sel, content: await VSReadFile(this._modelService, sel.fileURI) })) + ).then( + (files) => files.filter(file => file.content !== null) as CodeSelection[] + ) + + // add user's message to chat history + const instructions = userMessage + const userHistoryElt: ChatMessage = { role: 'user', content: chat_prompt(instructions, selections), displayContent: instructions, selections: selections } + this._addMessageToThread(threadId, userHistoryElt) + + const onDone = (content: string, error?: { message: string, fullError: Error | null }) => { + // add assistant's message to chat history, and clear selection + const assistantHistoryElt: ChatMessage = { role: 'assistant', content, displayContent: content || null } + this._addMessageToThread(threadId, assistantHistoryElt) + this._setStreamState(threadId, { messageSoFar: undefined, streamingToken: undefined, error }) + } + + this._setStreamState(threadId, { error: undefined }) + + const llmCancelToken = this._llmMessageService.sendLLMMessage({ + logging: { loggingName: 'Chat' }, + messages: [ + { role: 'system', content: chat_systemMessage }, + ...this.getCurrentThread().messages.map(m => ({ role: m.role, content: m.content || '(null)' })), + ], + onText: ({ newText, fullText }) => { + this._setStreamState(threadId, { messageSoFar: fullText }) + }, + onFinalMessage: ({ fullText: content }) => { + onDone(content) + }, + onError: (error) => { + console.log('Void Chat Error:', error) + onDone(this.streamState[threadId]?.messageSoFar ?? '', error) + }, + featureName: 'Ctrl+L', + + }) + if (llmCancelToken === null) return + this._setStreamState(threadId, { streamingToken: llmCancelToken }) + + } + + cancelStreaming(threadId: string) { + const llmCancelToken = this.streamState[threadId]?.streamingToken + if (llmCancelToken) this._llmMessageService.abort(llmCancelToken) + this._setStreamState(threadId, { streamingToken: undefined }) + } + + dismissStreamError(threadId: string): void { + this._setStreamState(threadId, { error: undefined }) + } + + + + // ---------- the rest ---------- + + getCurrentThread(): ChatThreads[string] { + const state = this.state + return state.allThreads[state.currentThreadId]; + } + + switchToThread(threadId: string) { + // console.log('threadId', threadId) + // console.log('messages', this.state.allThreads[threadId].messages) + this._setState({ currentThreadId: threadId }, true) + } + + + openNewThread() { + // if a thread with 0 messages already exists, switch to it + const { allThreads: currentThreads } = this.state + for (const threadId in currentThreads) { + if (currentThreads[threadId].messages.length === 0) { + this.switchToThread(threadId) + return + } + } + // otherwise, start a new thread + const newThread = newThreadObject() + + // update state + const newThreads: ChatThreads = { + ...currentThreads, + [newThread.id]: newThread + } + this._storeAllThreads(newThreads) + this._setState({ allThreads: newThreads, currentThreadId: newThread.id }, true) + } + + + _addMessageToThread(threadId: string, message: ChatMessage) { + const { allThreads } = this.state + + const oldThread = allThreads[threadId] + + // update state and store it + const newThreads = { + ...allThreads, + [oldThread.id]: { + ...oldThread, + lastModified: new Date().toISOString(), + messages: [...oldThread.messages, message], + } + } + this._storeAllThreads(newThreads) + this._setState({ allThreads: newThreads }, true) // the current thread just changed (it had a message added to it) + } + + + setStaging(stagingSelection: CodeStagingSelection[] | null): void { + this._setState({ currentStagingSelections: stagingSelection }, true) // this is a hack for now + } + +} + +registerSingleton(IChatThreadService, ChatThreadService, InstantiationType.Eager); + diff --git a/src/vs/workbench/contrib/void/browser/helpers/readFile.ts b/src/vs/workbench/contrib/void/browser/helpers/readFile.ts new file mode 100644 index 00000000..60e5dc5c --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/helpers/readFile.ts @@ -0,0 +1,10 @@ +import { URI } from '../../../../../base/common/uri' +import { EndOfLinePreference } from '../../../../../editor/common/model' +import { IModelService } from '../../../../../editor/common/services/model.js' + +// read files from VSCode +export const VSReadFile = async (modelService: IModelService, uri: URI): Promise => { + const model = modelService.getModel(uri) + if (!model) return null + return model.getValue(EndOfLinePreference.LF) +} diff --git a/src/vs/workbench/contrib/void/browser/prompt/prompts.ts b/src/vs/workbench/contrib/void/browser/prompt/prompts.ts index ba064384..a45bdf1c 100644 --- a/src/vs/workbench/contrib/void/browser/prompt/prompts.ts +++ b/src/vs/workbench/contrib/void/browser/prompt/prompts.ts @@ -6,7 +6,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { filenameToVscodeLanguage } from '../helpers/detectLanguage.js'; -import { CodeSelection } from '../threadHistoryService.js'; +import { CodeSelection } from '../chatThreadService.js'; export const chat_systemMessage = `\ You are a coding assistant. You are given a list of relevant files \`files\`, a selection that the user is making \`selection\`, and instructions to follow \`instructions\`. 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 9b32cf56..80988038 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, useThreadsState, useQuickEditState, useAccessor } from '../util/services.js'; +import { useSettingsState, useSidebarState, useChatThreadsState, useQuickEditState, useAccessor } from '../util/services.js'; import { TextAreaFns, VoidInputBox2 } from '../util/inputs.js'; import { QuickEditPropsType } from '../../../quickEditActions.js'; import { ButtonStop, ButtonSubmit, IconX } from '../sidebar-tsx/SidebarChat.js'; 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 93f95ea9..ac54e209 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 @@ -6,8 +6,8 @@ import React, { ButtonHTMLAttributes, FormEvent, FormHTMLAttributes, Fragment, useCallback, useEffect, useRef, useState } from 'react'; -import { useAccessor, useSidebarState, useThreadsState } from '../util/services.js'; -import { ChatMessage, CodeSelection, CodeStagingSelection, IThreadHistoryService } from '../../../threadHistoryService.js'; +import { useAccessor, useSidebarState, useChatThreadsState, useChatThreadsStreamState } from '../util/services.js'; +import { ChatMessage, CodeSelection, CodeStagingSelection } from '../../../chatThreadService.js'; import { BlockCode } from '../markdown/BlockCode.js'; import { ChatMarkdownRender } from '../markdown/ChatMarkdownRender.js'; @@ -250,13 +250,6 @@ const ScrollToBottomContainer = ({ children, className, style, scrollContainerRe }; -// read files from VSCode -const VSReadFile = async (modelService: IModelService, uri: URI): Promise => { - const model = modelService.getModel(uri) - if (!model) return null - return model.getValue(EndOfLinePreference.LF) -} - const getBasename = (pathStr: string) => { // 'unixify' path @@ -498,24 +491,24 @@ export const SidebarChat = () => { return () => disposables.forEach(d => d.dispose()) }, [sidebarStateService, textAreaRef]) - const { currentTab, isHistoryOpen } = useSidebarState() + const { isHistoryOpen } = useSidebarState() // threads state - const threadsState = useThreadsState() - const threadsStateService = accessor.get('IThreadHistoryService') + const chatThreadsState = useChatThreadsState() + const chatThreadsService = accessor.get('IChatThreadService') - const llmMessageService = accessor.get('ILLMMessageService') + const currentThread = chatThreadsService.getCurrentThread() + const previousMessages = currentThread?.messages ?? [] + const selections = chatThreadsState.currentStagingSelections + + // stream state + const chatThreadsStreamState = useChatThreadsStreamState(chatThreadsState.currentThreadId) + const isCurrThreadStreaming = !!chatThreadsStreamState?.streamingToken + const latestError = chatThreadsStreamState?.error + const messageSoFar = chatThreadsStreamState?.messageSoFar // ----- SIDEBAR CHAT state (local) ----- - // state of chat - const [messageStream, setMessageStream] = useState(null) - const [isLoading, setIsLoading] = useState(false) - const latestRequestIdRef = useRef(null) - - const [latestError, setLatestError] = useState[0] | null>(null) - - // state of current message const initVal = '' const [instructionsAreEmpty, setInstructionsAreEmpty] = useState(!initVal) @@ -527,110 +520,28 @@ export const SidebarChat = () => { useScrollbarStyles(sidebarRef) + const onSubmit = async () => { if (isDisabled) return - if (isLoading) return - - const currSelns = threadsStateService.state._currentStagingSelections ?? [] - const selections = !currSelns ? null : await Promise.all( - currSelns.map(async (sel) => ({ ...sel, content: await VSReadFile(modelService, sel.fileURI) })) - ).then( - (files) => files.filter(file => file.content !== null) as CodeSelection[] - ) - - - // // TODO don't save files to the thread history - // const selectedSnippets = currSelns.filter(sel => sel.selectionStr !== null) - // const selectedFiles = await Promise.all( // do not add these to the context history - // currSelns.filter(sel => sel.selectionStr === null) - // .map(async (sel) => ({ ...sel, content: await VSReadFile(modelService, sel.fileURI) })) - // ).then( - // (files) => files.filter(file => file.content !== null) as CodeSelection[] - // ) - // const contextToSendToLLM = '' - // const contextToAddToHistory = '' - - - // add system message to chat history - const systemPromptElt: ChatMessage = { role: 'system', content: chat_systemMessage } - threadsStateService.addMessageToCurrentThread(systemPromptElt) - - // add user's message to chat history - const instructions = textAreaRef.current?.value ?? '' - const userHistoryElt: ChatMessage = { role: 'user', content: chat_prompt(instructions, selections), displayContent: instructions, selections: selections } - threadsStateService.addMessageToCurrentThread(userHistoryElt) - - const currentThread = threadsStateService.getCurrentThread(threadsStateService.state) // the the instant state right now, don't wait for the React state + if (isCurrThreadStreaming) return // send message to LLM - setIsLoading(true) // must come before message is sent so onError will work - setLatestError(null) - if (textAreaRef.current) { - textAreaFnsRef.current?.setValue('') // triggers onChange - textAreaRef.current.blur(); - } - - const object: ServiceSendLLMMessageParams = { - logging: { loggingName: 'Chat' }, - messages: [...(currentThread?.messages ?? []).map(m => ({ role: m.role, content: m.content || '(null)' })),], - onText: ({ newText, fullText }) => setMessageStream(fullText), - onFinalMessage: ({ fullText: content }) => { - console.log('chat: running final message') - - // add assistant's message to chat history, and clear selection - const assistantHistoryElt: ChatMessage = { role: 'assistant', content, displayContent: content || null } - threadsStateService.addMessageToCurrentThread(assistantHistoryElt) - setMessageStream(null) - setIsLoading(false) - }, - onError: ({ message, fullError }) => { - console.log('chat: running error', message, fullError) - - // add assistant's message to chat history, and clear selection - let content = messageStream ?? ''; // just use the current content - const assistantHistoryElt: ChatMessage = { role: 'assistant', content, displayContent: content || null, } - threadsStateService.addMessageToCurrentThread(assistantHistoryElt) - - setMessageStream('') - setIsLoading(false) - - setLatestError({ message, fullError }) - }, - featureName: 'Ctrl+L', - - } - - const latestRequestId = llmMessageService.sendLLMMessage(object) - latestRequestIdRef.current = latestRequestId - - threadsStateService.setStaging([]) // clear staging + const userMessage = textAreaRef.current?.value ?? '' + await chatThreadsService.addUserMessageAndStreamResponse(userMessage) + chatThreadsService.setStaging([]) // clear staging + textAreaFnsRef.current?.setValue('') textAreaRef.current?.focus() // focus input after submit } const onAbort = () => { - // abort the LLM call - if (latestRequestIdRef.current) - llmMessageService.abort(latestRequestIdRef.current) - - // if messageStream was not empty, add it to the history - const llmContent = messageStream ?? '' - const assistantHistoryElt: ChatMessage = { role: 'assistant', content: llmContent, displayContent: messageStream || null, } - threadsStateService.addMessageToCurrentThread(assistantHistoryElt) - - setMessageStream('') - setIsLoading(false) - + const token = chatThreadsStreamState?.streamingToken + if (!token) return + chatThreadsService.cancelStreaming(token) } - const currentThread = threadsStateService.getCurrentThread(threadsState) - - const selections = threadsState._currentStagingSelections - - const previousMessages = currentThread?.messages ?? [] - // const [_test_messages, _set_test_messages] = useState([]) const keybindingString = accessor.get('IKeybindingService').lookupKeybinding(VOID_CTRL_L_ACTION_ID)?.getLabel() @@ -640,7 +551,7 @@ export const SidebarChat = () => { useEffect(() => { if (isHistoryOpen) scrollContainerRef.current?.scrollTo({ top: 0, left: 0 }) - }, [isHistoryOpen, currentThread?.id]) + }, [isHistoryOpen, currentThread.id]) return
{ )} {/* message stream */} - + @@ -697,15 +608,15 @@ export const SidebarChat = () => { <> {/* selections */} {(selections && selections.length !== 0) && - + } {/* error message */} - {latestError === null ? null : + {latestError === undefined ? null : { setLatestError(null) }} + onDismiss={() => { chatThreadsService.dismissStreamError(currentThread.id) }} showDismiss={true} /> } @@ -745,7 +656,7 @@ export const SidebarChat = () => {
{/* submit / stop button */} - {isLoading ? + {isCurrThreadStreaming ? // stop button { export const SidebarThreadSelector = () => { - const threadsState = useThreadsState() + const threadsState = useChatThreadsState() const accessor = useAccessor() - const threadsStateService = accessor.get('IThreadHistoryService') + const chatThreadsService = accessor.get('IChatThreadService') const sidebarStateService = accessor.get('ISidebarStateService') const { allThreads } = threadsState @@ -96,13 +95,13 @@ export const SidebarThreadSelector = () => { type='button' className={` hover:bg-void-bg-1 - ${threadsState._currentThreadId === pastThread.id ? 'bg-void-bg-1' : ''} + ${threadsState.currentThreadId === pastThread.id ? 'bg-void-bg-1' : ''} rounded-sm px-2 py-1 w-full text-left flex items-center `} - onClick={() => threadsStateService.switchToThread(pastThread.id)} + onClick={() => chatThreadsService.switchToThread(pastThread.id)} title={new Date(pastThread.createdAt).toLocaleString()} >
{`${firstMsg}`}
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 b920eba7..b5613ab6 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 @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------*/ import React, { useState, useEffect } from 'react' -import { ThreadsState } from '../../../threadHistoryService.js' +import { ThreadStreamState, ThreadsState } from '../../../chatThreadService.js' import { RefreshableProviderName, SettingsOfProvider } from '../../../../../../../platform/void/common/voidSettingsTypes.js' import { IDisposable } from '../../../../../../../base/common/lifecycle.js' import { VoidSidebarState } from '../../../sidebarStateService.js' @@ -30,7 +30,7 @@ import { IVoidSettingsService } from '../../../../../../../platform/void/common/ import { IInlineDiffsService } from '../../../inlineDiffsService.js'; import { IQuickEditStateService } from '../../../quickEditStateService.js'; import { ISidebarStateService } from '../../../sidebarStateService.js'; -import { IThreadHistoryService } from '../../../threadHistoryService.js'; +import { IChatThreadService } from '../../../chatThreadService.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js' import { ICodeEditorService } from '../../../../../../../editor/browser/services/codeEditorService.js' import { ICommandService } from '../../../../../../../platform/commands/common/commands.js' @@ -57,8 +57,11 @@ const quickEditStateListeners: Set<(s: VoidQuickEditState) => void> = new Set() let sidebarState: VoidSidebarState const sidebarStateListeners: Set<(s: VoidSidebarState) => void> = new Set() -let threadsState: ThreadsState -const threadsStateListeners: Set<(s: ThreadsState) => void> = new Set() +let chatThreadsState: ThreadsState +const chatThreadsStateListeners: Set<(s: ThreadsState) => void> = new Set() + +let chatThreadsStreamState: ThreadStreamState +const chatThreadsStreamStateListeners: Set<(threadId: string) => void> = new Set() let settingsState: VoidSettingsState const settingsStateListeners: Set<(s: VoidSettingsState) => void> = new Set() @@ -89,14 +92,14 @@ export const _registerServices = (accessor: ServicesAccessor) => { const stateServices = { quickEditStateService: accessor.get(IQuickEditStateService), sidebarStateService: accessor.get(ISidebarStateService), - threadsStateService: accessor.get(IThreadHistoryService), + chatThreadsStateService: accessor.get(IChatThreadService), settingsStateService: accessor.get(IVoidSettingsService), refreshModelService: accessor.get(IRefreshModelService), themeService: accessor.get(IThemeService), inlineDiffsService: accessor.get(IInlineDiffsService), } - const { sidebarStateService, quickEditStateService, settingsStateService, threadsStateService, refreshModelService, themeService, inlineDiffsService } = stateServices + const { sidebarStateService, quickEditStateService, settingsStateService, chatThreadsStateService, refreshModelService, themeService, inlineDiffsService } = stateServices quickEditState = quickEditStateService.state disposables.push( @@ -114,11 +117,20 @@ export const _registerServices = (accessor: ServicesAccessor) => { }) ) - threadsState = threadsStateService.state + chatThreadsState = chatThreadsStateService.state disposables.push( - threadsStateService.onDidChangeCurrentThread(() => { - threadsState = threadsStateService.state - threadsStateListeners.forEach(l => l(threadsState)) + chatThreadsStateService.onDidChangeCurrentThread(() => { + chatThreadsState = chatThreadsStateService.state + chatThreadsStateListeners.forEach(l => l(chatThreadsState)) + }) + ) + + // same service, different state + chatThreadsStreamState = chatThreadsStateService.streamState + disposables.push( + chatThreadsStateService.onDidChangeStreamState(({ threadId }) => { + chatThreadsStreamState = chatThreadsStateService.streamState + chatThreadsStreamStateListeners.forEach(l => l(threadId)) }) ) @@ -168,7 +180,7 @@ const getReactAccessor = (accessor: ServicesAccessor) => { IInlineDiffsService: accessor.get(IInlineDiffsService), IQuickEditStateService: accessor.get(IQuickEditStateService), ISidebarStateService: accessor.get(ISidebarStateService), - IThreadHistoryService: accessor.get(IThreadHistoryService), + IChatThreadService: accessor.get(IChatThreadService), IInstantiationService: accessor.get(IInstantiationService), ICodeEditorService: accessor.get(ICodeEditorService), @@ -242,17 +254,36 @@ export const useSettingsState = () => { return s } -export const useThreadsState = () => { - const [s, ss] = useState(threadsState) +export const useChatThreadsState = () => { + const [s, ss] = useState(chatThreadsState) useEffect(() => { - ss(threadsState) - threadsStateListeners.add(ss) - return () => { threadsStateListeners.delete(ss) } + ss(chatThreadsState) + chatThreadsStateListeners.add(ss) + return () => { chatThreadsStateListeners.delete(ss) } }, [ss]) return s } + + +export const useChatThreadsStreamState = (threadId: string) => { + const [s, ss] = useState(chatThreadsStreamState[threadId]) + useEffect(() => { + ss(chatThreadsStreamState[threadId]) + const listener = (threadId_: string) => { + if (threadId_ !== threadId) return + ss(chatThreadsStreamState[threadId]) + } + chatThreadsStreamStateListeners.add(listener) + return () => { chatThreadsStreamStateListeners.delete(listener) } + }, [ss, threadId]) + return s +} + + + + export const useRefreshModelState = () => { const [s, ss] = useState(refreshModelState) useEffect(() => { diff --git a/src/vs/workbench/contrib/void/browser/sidebarActions.ts b/src/vs/workbench/contrib/void/browser/sidebarActions.ts index fe9188c5..d33df07d 100644 --- a/src/vs/workbench/contrib/void/browser/sidebarActions.ts +++ b/src/vs/workbench/contrib/void/browser/sidebarActions.ts @@ -11,7 +11,7 @@ import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; -import { CodeStagingSelection, IThreadHistoryService } from './threadHistoryService.js'; +import { CodeStagingSelection, IChatThreadService } from './chatThreadService.js'; import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; import { IRange } from '../../../../editor/common/core/range.js'; @@ -126,8 +126,8 @@ registerAction2(class extends Action2 { } // add selection to staging - const threadHistoryService = accessor.get(IThreadHistoryService) - const currentStaging = threadHistoryService.state._currentStagingSelections + const chatThreadService = accessor.get(IChatThreadService) + const currentStaging = chatThreadService.state.currentStagingSelections const currentStagingEltIdx = currentStaging?.findIndex(s => s.fileURI.fsPath === model.uri.fsPath && s.range?.startLineNumber === selection.range?.startLineNumber @@ -136,7 +136,7 @@ registerAction2(class extends Action2 { // if matches with existing selection, overwrite if (currentStagingEltIdx !== undefined && currentStagingEltIdx !== -1) { - threadHistoryService.setStaging([ + chatThreadService.setStaging([ ...currentStaging!.slice(0, currentStagingEltIdx), selection, ...currentStaging!.slice(currentStagingEltIdx + 1, Infinity) @@ -144,7 +144,7 @@ registerAction2(class extends Action2 { } // if no match, add else { - threadHistoryService.setStaging([...(currentStaging ?? []), selection]) + chatThreadService.setStaging([...(currentStaging ?? []), selection]) } } @@ -184,8 +184,8 @@ registerAction2(class extends Action2 { stateService.setState({ isHistoryOpen: false, currentTab: 'chat' }) stateService.fireFocusChat() - const historyService = accessor.get(IThreadHistoryService) - historyService.startNewThread() + const chatThreadService = accessor.get(IChatThreadService) + chatThreadService.openNewThread() } }) diff --git a/src/vs/workbench/contrib/void/browser/threadHistoryService.ts b/src/vs/workbench/contrib/void/browser/threadHistoryService.ts deleted file mode 100644 index 58e471a6..00000000 --- a/src/vs/workbench/contrib/void/browser/threadHistoryService.ts +++ /dev/null @@ -1,219 +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 { Disposable } from '../../../../base/common/lifecycle.js'; -import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; -import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; - -import { URI } from '../../../../base/common/uri.js'; -import { Emitter, Event } from '../../../../base/common/event.js'; -import { IAutocompleteService } from './autocompleteService.js'; -import { IRange } from '../../../../editor/common/core/range.js'; - -export type CodeSelection = { - fileURI: URI; - selectionStr: string | null; - content: string; // TODO remove this (replace `selectionStr` with `content`) - range: IRange; -} - -// if selectionStr is null, it means to use the entire file at send time -export type CodeStagingSelection = { - type: 'Selection', - fileURI: URI, - selectionStr: string, - range: IRange -} | { - type: 'File', - fileURI: URI, - selectionStr: null, - range: null -} - - -// WARNING: changing this format is a big deal!!!!!! need to migrate old format to new format on users' computers so people don't get errors. -export type ChatMessage = - | { - role: 'user'; - content: string | null; // content sent to the llm - allowed to be '', will be replaced with (empty) - displayContent: string | null; // content displayed to user - allowed to be '', will be ignored - selections: CodeSelection[] | null; // the user's selection - } - | { - 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 - } - | { - role: 'system'; - content: string; - displayContent?: undefined; - } - -// a 'thread' means a chat message history -export type ChatThreads = { - [id: string]: { - id: string; // store the id here too - createdAt: string; // ISO string - lastModified: string; // ISO string - messages: ChatMessage[]; - - // editing state - isBeingEdited: boolean; - _currentStagingSelections: CodeStagingSelection[] | null; - }; -} - -export type ThreadsState = { - allThreads: ChatThreads; - _currentThreadId: string | null; // intended for internal use only - _currentStagingSelections: CodeStagingSelection[] | null; -} - - -const newThreadObject = () => { - const now = new Date().toISOString() - return { - id: new Date().getTime().toString(), - createdAt: now, - lastModified: now, - messages: [], - isBeingEdited: false, - _currentStagingSelections: null, - } -} - -const THREAD_STORAGE_KEY = 'void.threadHistory' - -export interface IThreadHistoryService { - readonly _serviceBrand: undefined; - - readonly state: ThreadsState; - onDidChangeCurrentThread: Event; - - getCurrentThread(state: ThreadsState): ChatThreads[string] | null; - startNewThread(): void; - switchToThread(threadId: string): void; - addMessageToCurrentThread(message: ChatMessage): void; - - setStaging(stagingSelection: CodeStagingSelection[] | null): void; - -} - -export const IThreadHistoryService = createDecorator('voidThreadHistoryService'); -class ThreadHistoryService extends Disposable implements IThreadHistoryService { - _serviceBrand: undefined; - - // this fires when the current thread changes at all (a switch of currentThread, or a message added to it, etc) - private readonly _onDidChangeCurrentThread = new Emitter(); - readonly onDidChangeCurrentThread: Event = this._onDidChangeCurrentThread.event; - - state: ThreadsState // allThreads is persisted, currentThread is not - - constructor( - @IStorageService private readonly _storageService: IStorageService, - @IAutocompleteService private readonly _autocomplete: IAutocompleteService, - ) { - super() - this._autocomplete - - this.state = { - allThreads: this._readAllThreads(), - _currentThreadId: null, - _currentStagingSelections: null, - } - } - - - private _readAllThreads(): ChatThreads { - const threads = this._storageService.get(THREAD_STORAGE_KEY, StorageScope.APPLICATION) - return threads ? JSON.parse(threads) : {} - } - - private _storeAllThreads(threads: ChatThreads) { - this._storageService.store(THREAD_STORAGE_KEY, JSON.stringify(threads), StorageScope.APPLICATION, StorageTarget.USER) - } - - // this should be the only place this.state = ... appears besides constructor - private _setState(state: Partial, affectsCurrent: boolean) { - this.state = { - ...this.state, - ...state - } - if (affectsCurrent) - this._onDidChangeCurrentThread.fire() - } - - // must "prove" that you have access to the current state by providing it - getCurrentThread(state: ThreadsState): ChatThreads[string] | null { - return state._currentThreadId ? state.allThreads[state._currentThreadId] ?? null : null; - } - - switchToThread(threadId: string) { - console.log('threadId', threadId) - console.log('messages', this.state.allThreads[threadId].messages) - this._setState({ _currentThreadId: threadId }, true) - } - - - startNewThread() { - // if a thread with 0 messages already exists, switch to it - const { allThreads: currentThreads } = this.state - for (const threadId in currentThreads) { - if (currentThreads[threadId].messages.length === 0) { - this.switchToThread(threadId) - return - } - } - // otherwise, start a new thread - const newThread = newThreadObject() - - // update state - const newThreads = { - ...currentThreads, - [newThread.id]: newThread - } - this._storeAllThreads(newThreads) - this._setState({ allThreads: newThreads, _currentThreadId: newThread.id }, true) - } - - - addMessageToCurrentThread(message: ChatMessage) { - console.log('adding ', message.role, 'to chat') - const { allThreads, _currentThreadId } = this.state - - // get the current thread, or create one - let currentThread: ChatThreads[string] - if (_currentThreadId && (_currentThreadId in allThreads)) { - currentThread = allThreads[_currentThreadId] - } - else { - currentThread = newThreadObject() - this.state._currentThreadId = currentThread.id - } - - // update state and store it - const newThreads = { - ...allThreads, - [currentThread.id]: { - ...currentThread, - lastModified: new Date().toISOString(), - messages: [...currentThread.messages, message], - } - } - this._storeAllThreads(newThreads) - this._setState({ allThreads: newThreads }, true) // the current thread just changed (it had a message added to it) - } - - - setStaging(stagingSelection: CodeStagingSelection[] | null): void { - this._setState({ _currentStagingSelections: stagingSelection }, true) // this is a hack for now - } - -} - -registerSingleton(IThreadHistoryService, ThreadHistoryService, InstantiationType.Eager); - diff --git a/src/vs/workbench/contrib/void/browser/void.contribution.ts b/src/vs/workbench/contrib/void/browser/void.contribution.ts index 536b0ca1..1ab6ebd6 100644 --- a/src/vs/workbench/contrib/void/browser/void.contribution.ts +++ b/src/vs/workbench/contrib/void/browser/void.contribution.ts @@ -16,7 +16,7 @@ import './sidebarStateService.js' import './quickEditActions.js' // register Thread History -import './threadHistoryService.js' +import './chatThreadService.js' // register Autocomplete import './autocompleteService.js'