mirror of
https://github.com/voideditor/void
synced 2026-05-24 09:58:23 +00:00
can have many streams at once!
This commit is contained in:
parent
d43453e5ed
commit
471333ec16
10 changed files with 403 additions and 369 deletions
302
src/vs/workbench/contrib/void/browser/chatThreadService.ts
Normal file
302
src/vs/workbench/contrib/void/browser/chatThreadService.ts
Normal file
|
|
@ -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<void>;
|
||||
onDidChangeStreamState: Event<{ threadId: string }>
|
||||
|
||||
getCurrentThread(): ChatThreads[string];
|
||||
openNewThread(): void;
|
||||
switchToThread(threadId: string): void;
|
||||
|
||||
setStaging(stagingSelection: CodeStagingSelection[] | null): void;
|
||||
|
||||
addUserMessageAndStreamResponse(userMessage: string): Promise<void>;
|
||||
cancelStreaming(threadId: string): void;
|
||||
dismissStreamError(threadId: string): void;
|
||||
|
||||
}
|
||||
|
||||
export const IChatThreadService = createDecorator<IChatThreadService>('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<void>();
|
||||
readonly onDidChangeCurrentThread: Event<void> = 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<ThreadsState>, affectsCurrent: boolean) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
...state
|
||||
}
|
||||
if (affectsCurrent)
|
||||
this._onDidChangeCurrentThread.fire()
|
||||
}
|
||||
|
||||
private _setStreamState(threadId: string, state: Partial<NonNullable<ThreadStreamState[string]>>) {
|
||||
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);
|
||||
|
||||
10
src/vs/workbench/contrib/void/browser/helpers/readFile.ts
Normal file
10
src/vs/workbench/contrib/void/browser/helpers/readFile.ts
Normal file
|
|
@ -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<string | null> => {
|
||||
const model = modelService.getModel(uri)
|
||||
if (!model) return null
|
||||
return model.getValue(EndOfLinePreference.LF)
|
||||
}
|
||||
|
|
@ -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\`.
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<string | null> => {
|
||||
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<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const latestRequestIdRef = useRef<string | null>(null)
|
||||
|
||||
const [latestError, setLatestError] = useState<Parameters<OnError>[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<string[]>([])
|
||||
|
||||
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 <div
|
||||
ref={sidebarRef}
|
||||
|
|
@ -670,7 +581,7 @@ export const SidebarChat = () => {
|
|||
)}
|
||||
|
||||
{/* message stream */}
|
||||
<ChatBubble chatMessage={{ role: 'assistant', content: messageStream, displayContent: messageStream || null }} isLoading={isLoading} />
|
||||
<ChatBubble chatMessage={{ role: 'assistant', content: messageSoFar ?? '', displayContent: messageSoFar || null }} isLoading={isCurrThreadStreaming} />
|
||||
|
||||
</ScrollToBottomContainer>
|
||||
|
||||
|
|
@ -697,15 +608,15 @@ export const SidebarChat = () => {
|
|||
<>
|
||||
{/* selections */}
|
||||
{(selections && selections.length !== 0) &&
|
||||
<SelectedFiles type='staging' selections={selections} setStaging={threadsStateService.setStaging.bind(threadsStateService)} />
|
||||
<SelectedFiles type='staging' selections={selections} setStaging={chatThreadsService.setStaging.bind(chatThreadsService)} />
|
||||
}
|
||||
|
||||
{/* error message */}
|
||||
{latestError === null ? null :
|
||||
{latestError === undefined ? null :
|
||||
<ErrorDisplay
|
||||
message={latestError.message}
|
||||
fullError={latestError.fullError}
|
||||
onDismiss={() => { setLatestError(null) }}
|
||||
onDismiss={() => { chatThreadsService.dismissStreamError(currentThread.id) }}
|
||||
showDismiss={true}
|
||||
/>
|
||||
}
|
||||
|
|
@ -745,7 +656,7 @@ export const SidebarChat = () => {
|
|||
</div>
|
||||
|
||||
{/* submit / stop button */}
|
||||
{isLoading ?
|
||||
{isCurrThreadStreaming ?
|
||||
// stop button
|
||||
<ButtonStop
|
||||
onClick={onAbort}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,7 @@
|
|||
*--------------------------------------------------------------------------------------*/
|
||||
|
||||
import React from "react";
|
||||
import { useAccessor, useThreadsState } from '../util/services.js';
|
||||
import { IThreadHistoryService } from '../../../threadHistoryService.js';
|
||||
import { useAccessor, useChatThreadsState } from '../util/services.js';
|
||||
import { ISidebarStateService } from '../../../sidebarStateService.js';
|
||||
import { IconX } from './SidebarChat.js';
|
||||
|
||||
|
|
@ -20,10 +19,10 @@ const truncate = (s: string) => {
|
|||
|
||||
|
||||
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()}
|
||||
>
|
||||
<div className='truncate'>{`${firstMsg}`}</div>
|
||||
|
|
|
|||
|
|
@ -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<ThreadStreamState[string] | undefined>(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(() => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
|
||||
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<IThreadHistoryService>('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<void>();
|
||||
readonly onDidChangeCurrentThread: Event<void> = 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<ThreadsState>, 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);
|
||||
|
||||
|
|
@ -16,7 +16,7 @@ import './sidebarStateService.js'
|
|||
import './quickEditActions.js'
|
||||
|
||||
// register Thread History
|
||||
import './threadHistoryService.js'
|
||||
import './chatThreadService.js'
|
||||
|
||||
// register Autocomplete
|
||||
import './autocompleteService.js'
|
||||
|
|
|
|||
Loading…
Reference in a new issue