mirror of
https://github.com/voideditor/void
synced 2026-05-23 17:38:23 +00:00
duplicate thread, retry, search_in_file
This commit is contained in:
parent
902d419026
commit
b02b2f0c89
7 changed files with 175 additions and 33 deletions
|
|
@ -38,7 +38,6 @@ registerAction2(class extends Action2 {
|
|||
});
|
||||
}
|
||||
async run(accessor: ServicesAccessor): Promise<void> {
|
||||
console.log('hi')
|
||||
const n = accessor.get(IDummyService)
|
||||
console.log('Hi', n._serviceBrand)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,8 +33,13 @@ import { truncate } from '../../../../base/common/strings.js';
|
|||
import { THREAD_STORAGE_KEY } from '../common/storageKeys.js';
|
||||
import { IConvertToLLMMessageService } from './convertToLLMMessageService.js';
|
||||
import { timeout } from '../../../../base/common/async.js';
|
||||
import { deepClone } from '../../../../base/common/objects.js';
|
||||
|
||||
|
||||
// related to retrying when LLM message has error
|
||||
const CHAT_RETRIES = 3
|
||||
const RETRY_DELAY = 2500
|
||||
|
||||
|
||||
export const findStagingSelectionIndex = (currentSelections: StagingSelectionItem[] | undefined, newSelection: StagingSelectionItem): number | null => {
|
||||
if (!currentSelections) return null
|
||||
|
|
@ -180,9 +185,12 @@ export interface IChatThreadService {
|
|||
|
||||
getCurrentThread(): ThreadType;
|
||||
openNewThread(): void;
|
||||
deleteThread(threadId: string): void;
|
||||
switchToThread(threadId: string): void;
|
||||
|
||||
// thread selector
|
||||
deleteThread(threadId: string): void;
|
||||
duplicateThread(threadId: string): void;
|
||||
|
||||
// exposed getters/setters
|
||||
// these all apply to current thread
|
||||
getCurrentMessageState: (messageIdx: number) => UserMessageState
|
||||
|
|
@ -345,7 +353,6 @@ class ChatThreadService extends Disposable implements IChatThreadService {
|
|||
else throw new Error(`setStreamState`)
|
||||
}
|
||||
|
||||
console.log('changeStreamState', threadId, state)
|
||||
this._onDidChangeStreamState.fire({ threadId })
|
||||
}
|
||||
|
||||
|
|
@ -369,7 +376,8 @@ class ChatThreadService extends Disposable implements IChatThreadService {
|
|||
if (!messages) return false
|
||||
const lastMsg = messages[messages.length - 1]
|
||||
if (!lastMsg) return false
|
||||
if (lastMsg.role === 'tool' && (lastMsg.type === 'running_now' || lastMsg.type === 'tool_request')) {
|
||||
|
||||
if (lastMsg.role === 'tool' && lastMsg.type !== 'invalid_params') {
|
||||
this._editMessageInThread(threadId, messages.length - 1, tool)
|
||||
return true
|
||||
}
|
||||
|
|
@ -386,9 +394,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
|
|||
if (!thread) return // should never happen
|
||||
|
||||
const lastMsg = thread.messages[thread.messages.length - 1]
|
||||
if (!(
|
||||
lastMsg.role === 'tool' && (lastMsg.type === 'tool_request')
|
||||
)) return // should never happen
|
||||
if (!(lastMsg.role === 'tool' && lastMsg.type === 'tool_request')) return // should never happen
|
||||
|
||||
const callThisToolFirst: ToolMessage<ToolName> = lastMsg
|
||||
|
||||
|
|
@ -404,7 +410,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
|
|||
const lastMsg = thread.messages[thread.messages.length - 1]
|
||||
|
||||
let params: ToolCallParams[ToolName]
|
||||
if (lastMsg.role === 'tool' && (lastMsg.type === 'running_now' || lastMsg.type === 'tool_request')) {
|
||||
if (lastMsg.role === 'tool' && lastMsg.type !== 'invalid_params') {
|
||||
params = lastMsg.params
|
||||
}
|
||||
else return
|
||||
|
|
@ -585,12 +591,6 @@ class ChatThreadService extends Disposable implements IChatThreadService {
|
|||
isRunningWhenEnd = undefined
|
||||
nMessagesSent += 1
|
||||
|
||||
let resMessageIsDonePromise: (toolCall?: RawToolCallObj | undefined) => void // resolves when user approves this tool use (or if tool doesn't require approval)
|
||||
const messageIsDonePromise = new Promise<RawToolCallObj | undefined>((res, rej) => { resMessageIsDonePromise = res })
|
||||
|
||||
// send llm message
|
||||
this._setStreamState(threadId, { isRunning: 'LLM' }, 'merge')
|
||||
|
||||
const chatMessages = this.state.allThreads[threadId]?.messages ?? []
|
||||
const { messages, separateSystemMessage } = await this._convertToLLMMessagesService.prepareLLMChatMessages({
|
||||
chatMessages,
|
||||
|
|
@ -598,15 +598,18 @@ class ChatThreadService extends Disposable implements IChatThreadService {
|
|||
chatMode
|
||||
})
|
||||
|
||||
|
||||
let aborted = false
|
||||
|
||||
let shouldRetry = true
|
||||
let nAttempts = 0
|
||||
|
||||
while (shouldRetry) {
|
||||
shouldRetry = false
|
||||
|
||||
let resMessageIsDonePromise: (toolCall?: RawToolCallObj | undefined) => void // resolves when user approves this tool use (or if tool doesn't require approval)
|
||||
const messageIsDonePromise = new Promise<RawToolCallObj | undefined>((res, rej) => { resMessageIsDonePromise = res })
|
||||
|
||||
// send llm message
|
||||
this._setStreamState(threadId, { isRunning: 'LLM' }, 'merge')
|
||||
|
||||
const llmCancelToken = this._llmMessageService.sendLLMMessage({
|
||||
messagesType: 'chatMessages',
|
||||
chatMode,
|
||||
|
|
@ -632,7 +635,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
|
|||
nAttempts += 1
|
||||
shouldRetry = true
|
||||
this._setStreamState(threadId, { displayContentSoFar: undefined, reasoningSoFar: undefined, streamingToken: undefined, toolCallSoFar: undefined }, 'merge')
|
||||
timeout(2500).then(() => { resMessageIsDonePromise() })
|
||||
timeout(RETRY_DELAY).then(() => { resMessageIsDonePromise() })
|
||||
}
|
||||
else {
|
||||
// const toolCallSoFar = this.streamState[threadId]?.toolCallSoFar
|
||||
|
|
@ -659,13 +662,14 @@ class ChatThreadService extends Disposable implements IChatThreadService {
|
|||
}
|
||||
this._setStreamState(threadId, { streamingToken: llmCancelToken }, 'merge') // new stream token for the new message
|
||||
const toolCall = await messageIsDonePromise // wait for message to complete
|
||||
this._setStreamState(threadId, { streamingToken: undefined }, 'merge') // streaming message is done
|
||||
|
||||
if (shouldRetry) {
|
||||
continue
|
||||
}
|
||||
if (aborted) {
|
||||
return
|
||||
}
|
||||
this._setStreamState(threadId, { streamingToken: undefined }, 'merge') // streaming message is done
|
||||
|
||||
// call tool if there is one
|
||||
const tool: RawToolCallObj | undefined = toolCall
|
||||
|
|
@ -884,7 +888,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
|
|||
const [_, toIdx] = c
|
||||
if (toIdx === fromIdx) return
|
||||
|
||||
console.log(`going from ${fromIdx} to ${toIdx}`)
|
||||
// console.log(`going from ${fromIdx} to ${toIdx}`)
|
||||
|
||||
// update the user's checkpoint
|
||||
this._addUserModificationsToCurrCheckpoint({ threadId })
|
||||
|
|
@ -1433,6 +1437,22 @@ We only need to do it for files that were edited since `from`, ie files between
|
|||
this._setState({ ...this.state, allThreads: newThreads }, true)
|
||||
}
|
||||
|
||||
duplicateThread(threadId: string) {
|
||||
const { allThreads: currentThreads } = this.state
|
||||
const threadToDuplicate = currentThreads[threadId]
|
||||
if (!threadToDuplicate) return
|
||||
const newThread = {
|
||||
...deepClone(threadToDuplicate),
|
||||
id: generateUuid(),
|
||||
}
|
||||
const newThreads = {
|
||||
...currentThreads,
|
||||
[newThread.id]: newThread,
|
||||
}
|
||||
this._storeAllThreads(newThreads)
|
||||
this._setState({ allThreads: newThreads }, true)
|
||||
}
|
||||
|
||||
|
||||
private _addMessageToThread(threadId: string, message: ChatMessage) {
|
||||
const { allThreads } = this.state
|
||||
|
|
|
|||
|
|
@ -1232,6 +1232,7 @@ const titleOfToolName = {
|
|||
'open_bg_terminal': { done: `Opened terminal`, proposed: 'Open terminal', running: loadingTitleWrapper('Opening terminal') },
|
||||
'kill_bg_terminal': { done: `Killed terminal`, proposed: 'Kill terminal', running: loadingTitleWrapper('Killing terminal') },
|
||||
'read_lint_errors': { done: `Read lint errors`, proposed: 'Read lint errors', running: loadingTitleWrapper('Reading lint errors') },
|
||||
'search_in_file': { done: 'Searched in file', proposed: 'Search in file', running: loadingTitleWrapper('Searching in file') },
|
||||
} as const satisfies Record<ToolName, { done: any, proposed: any, running: any }>
|
||||
|
||||
const getTitle = (toolMessage: Pick<ChatMessage & { role: 'tool' }, 'name' | 'type'>): React.ReactNode => {
|
||||
|
|
@ -1281,6 +1282,13 @@ const toolNameToDesc = (toolName: ToolName, _toolParams: ToolCallParams[ToolName
|
|||
desc1: `"${toolParams.query}"`,
|
||||
}
|
||||
},
|
||||
'search_in_file': () => {
|
||||
const toolParams = _toolParams as ToolCallParams['search_in_file'];
|
||||
return {
|
||||
desc1: `"${toolParams.query}"`,
|
||||
desc1Info: getRelative(toolParams.uri, accessor),
|
||||
};
|
||||
},
|
||||
'create_file_or_folder': () => {
|
||||
const toolParams = _toolParams as ToolCallParams['create_file_or_folder']
|
||||
return {
|
||||
|
|
@ -1692,8 +1700,9 @@ const toolNameToComponent: { [T in ToolName]: { resultWrapper: ResultWrapper<T>,
|
|||
const { rawParams, params } = toolMessage
|
||||
const componentParams: ToolHeaderParams = { title, desc1, desc1Info, isError, icon, }
|
||||
|
||||
if (params.includePattern)
|
||||
if (params.includePattern) {
|
||||
componentParams.info = `Only search in ${params.includePattern}`
|
||||
}
|
||||
|
||||
if (toolMessage.type === 'success') {
|
||||
const { result, rawParams } = toolMessage
|
||||
|
|
@ -1740,9 +1749,16 @@ const toolNameToComponent: { [T in ToolName]: { resultWrapper: ResultWrapper<T>,
|
|||
const { rawParams, params } = toolMessage
|
||||
const componentParams: ToolHeaderParams = { title, desc1, desc1Info, isError, icon, }
|
||||
|
||||
if (params.searchInFolder) {
|
||||
const rel = getRelative(params.searchInFolder, accessor)
|
||||
if (rel) componentParams.info = `Only search in ${rel}`
|
||||
if (params.searchInFolder || params.isRegex) {
|
||||
let info: string[] = []
|
||||
if (params.searchInFolder) {
|
||||
const rel = getRelative(params.searchInFolder, accessor)
|
||||
if (rel) info.push(`Only search in ${rel}`)
|
||||
}
|
||||
if (params.isRegex) {
|
||||
info.push(`Treat as regex`)
|
||||
}
|
||||
componentParams.info = info.join('; ')
|
||||
}
|
||||
|
||||
if (toolMessage.type === 'success') {
|
||||
|
|
@ -1774,6 +1790,46 @@ const toolNameToComponent: { [T in ToolName]: { resultWrapper: ResultWrapper<T>,
|
|||
}
|
||||
},
|
||||
|
||||
'search_in_file': {
|
||||
resultWrapper: ({ toolMessage }) => {
|
||||
const accessor = useAccessor();
|
||||
const toolsService = accessor.get('IToolsService');
|
||||
const title = getTitle(toolMessage);
|
||||
const { desc1, desc1Info } = toolNameToDesc(toolMessage.name, toolMessage.params, accessor);
|
||||
const icon = null;
|
||||
|
||||
if (toolMessage.type === 'tool_request' || toolMessage.type === 'rejected' || toolMessage.type === 'running_now') return null;
|
||||
|
||||
const isError = toolMessage.type === 'tool_error';
|
||||
const { rawParams, params } = toolMessage;
|
||||
const componentParams: ToolHeaderParams = { title, desc1, desc1Info, isError, icon };
|
||||
if (params.isRegex) componentParams.info = 'Treat as regex'
|
||||
|
||||
if (toolMessage.type === 'success') {
|
||||
const { result } = toolMessage; // result is array of snippets
|
||||
componentParams.numResults = result.lines.length;
|
||||
componentParams.children = result.lines.length === 0 ? undefined :
|
||||
<ToolChildrenWrapper>
|
||||
<CodeChildren>
|
||||
<pre className='font-mono whitespace-pre'>
|
||||
{toolsService.stringOfResult['search_in_file'](params, result)}
|
||||
</pre>
|
||||
</CodeChildren>
|
||||
</ToolChildrenWrapper>
|
||||
}
|
||||
else {
|
||||
const { result } = toolMessage;
|
||||
componentParams.children = <ToolChildrenWrapper>
|
||||
<CodeChildren>
|
||||
{result}
|
||||
</CodeChildren>
|
||||
</ToolChildrenWrapper>;
|
||||
}
|
||||
|
||||
return <ToolHeaderWrapper {...componentParams} />;
|
||||
}
|
||||
},
|
||||
|
||||
'read_lint_errors': {
|
||||
resultWrapper: ({ toolMessage }) => {
|
||||
const accessor = useAccessor()
|
||||
|
|
@ -2163,7 +2219,7 @@ const Checkpoint = ({ message, threadId, messageIdx, isCheckpointGhost, threadIs
|
|||
{...isDisabled ? {
|
||||
'data-tooltip-id': 'void-tooltip',
|
||||
'data-tooltip-content': `Disabled ${isRunning ? 'when running' : 'because another thread is running'}`,
|
||||
'data-tooltip-place': 'left',
|
||||
'data-tooltip-place': 'top',
|
||||
} : {}}
|
||||
>
|
||||
Checkpoint
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { useMemo, useState } from 'react';
|
|||
import { CopyButton, IconShell1 } from '../markdown/ApplyBlockHoverButtons.js';
|
||||
import { useAccessor, useChatThreadsState, useChatThreadsStreamState, useFullChatThreadsStreamState, useSettingsState } from '../util/services.js';
|
||||
import { IconX } from './SidebarChat.js';
|
||||
import { Check, LoaderCircle, MessageCircleQuestion, Trash2, UserCheck, X } from 'lucide-react';
|
||||
import { Check, Copy, Icon, LoaderCircle, MessageCircleQuestion, Trash2, UserCheck, X } from 'lucide-react';
|
||||
import { IsRunningType, ThreadType } from '../../../chatThreadService.js';
|
||||
|
||||
|
||||
|
|
@ -247,6 +247,21 @@ const formatTime = (date: Date) => {
|
|||
};
|
||||
|
||||
|
||||
const DuplicateButton = ({ threadId }: { threadId: string }) => {
|
||||
const accessor = useAccessor()
|
||||
const chatThreadsService = accessor.get('IChatThreadService')
|
||||
return <IconShell1
|
||||
Icon={Copy}
|
||||
className='size-[11px]'
|
||||
onClick={() => { chatThreadsService.duplicateThread(threadId); }}
|
||||
data-tooltip-id='void-tooltip'
|
||||
data-tooltip-place='top'
|
||||
data-tooltip-content='Duplicate thread'
|
||||
>
|
||||
</IconShell1>
|
||||
|
||||
}
|
||||
|
||||
const TrashButton = ({ threadId }: { threadId: string }) => {
|
||||
|
||||
const accessor = useAccessor()
|
||||
|
|
@ -374,6 +389,9 @@ const PastThreadElement = ({ pastThread, idx, hoveredIdx, setHoveredIdx, isRunni
|
|||
<div className="flex items-center gap-2 opacity-60">
|
||||
{idx === hoveredIdx ?
|
||||
<>
|
||||
{/* trash icon */}
|
||||
<DuplicateButton threadId={pastThread.id} />
|
||||
|
||||
{/* trash icon */}
|
||||
<TrashButton threadId={pastThread.id} />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -195,14 +195,23 @@ export class ToolsService implements IToolsService {
|
|||
is_regex: isRegexUnknown,
|
||||
page_number: pageNumberUnknown
|
||||
} = params
|
||||
|
||||
const queryStr = validateStr('query', queryUnknown)
|
||||
const pageNumber = validatePageNum(pageNumberUnknown)
|
||||
|
||||
const searchInFolder = validateOptionalURI(searchInFolderUnknown)
|
||||
const isRegex = validateBoolean(isRegexUnknown, { default: false })
|
||||
|
||||
return { query: queryStr, searchInFolder, isRegex, pageNumber }
|
||||
return {
|
||||
query: queryStr,
|
||||
isRegex,
|
||||
searchInFolder,
|
||||
pageNumber
|
||||
}
|
||||
},
|
||||
search_in_file: (params: RawToolParamsObj) => {
|
||||
const { uri: uriStr, query: queryUnknown, is_regex: isRegexUnknown } = params;
|
||||
const uri = validateURI(uriStr);
|
||||
const query = validateStr('query', queryUnknown);
|
||||
const isRegex = validateBoolean(isRegexUnknown, { default: false });
|
||||
return { uri, query, isRegex };
|
||||
},
|
||||
|
||||
read_lint_errors: (params: RawToolParamsObj) => {
|
||||
|
|
@ -333,6 +342,24 @@ export class ToolsService implements IToolsService {
|
|||
const hasNextPage = (data.results.length - 1) - toIdx >= 1
|
||||
return { result: { queryStr, uris, hasNextPage } }
|
||||
},
|
||||
search_in_file: async ({ uri, query, isRegex }) => {
|
||||
await voidModelService.initializeModel(uri);
|
||||
const { model } = await voidModelService.getModelSafe(uri);
|
||||
if (model === null) { throw new Error(`No contents; File does not exist.`); }
|
||||
const contents = model.getValue(EndOfLinePreference.LF);
|
||||
const contentOfLine = contents.split('\n');
|
||||
const totalLines = contentOfLine.length;
|
||||
const regex = isRegex ? new RegExp(query) : null;
|
||||
const lines: number[] = []
|
||||
for (let i = 0; i < totalLines; i++) {
|
||||
const line = contentOfLine[i];
|
||||
if ((isRegex && regex!.test(line)) || (!isRegex && line.includes(query))) {
|
||||
const matchLine = i + 1;
|
||||
lines.push(matchLine);
|
||||
}
|
||||
}
|
||||
return { result: { lines } };
|
||||
},
|
||||
|
||||
read_lint_errors: async ({ uri }) => {
|
||||
await timeout(1000)
|
||||
|
|
@ -435,6 +462,15 @@ export class ToolsService implements IToolsService {
|
|||
search_for_files: (params, result) => {
|
||||
return result.uris.map(uri => uri.fsPath).join('\n') + nextPageStr(result.hasNextPage)
|
||||
},
|
||||
search_in_file: (params, result) => {
|
||||
const { model } = voidModelService.getModel(params.uri)
|
||||
if (!model) return '<Error getting string of result>'
|
||||
const lines = result.lines.map(n => {
|
||||
const lineContent = model.getValueInRange({ startLineNumber: n, startColumn: 1, endLineNumber: n, endColumn: Number.MAX_SAFE_INTEGER }, EndOfLinePreference.LF)
|
||||
return `Line ${n}:\n\`\`\`\n${lineContent}\n\`\`\``
|
||||
}).join('\n\n');
|
||||
return lines;
|
||||
},
|
||||
read_lint_errors: (params, result) => {
|
||||
return result.lintErrors ?
|
||||
stringifyLintErrors(result.lintErrors)
|
||||
|
|
|
|||
|
|
@ -111,8 +111,8 @@ export const voidTools = {
|
|||
description: `Returns full contents of a given file.`,
|
||||
params: {
|
||||
...uriParam('file'),
|
||||
start_line: { description: 'Optional. Only fill this in if you already know the line numbers you need to search. Defaults to 1.' },
|
||||
end_line: { description: 'Optional. Only fill this in if you already know the line numbers you need to search. Defaults to Infinity.' },
|
||||
start_line: { description: 'Optional. Do NOT fill this in unless you already know the line numbers you need to search. Defaults to 1.' },
|
||||
end_line: { description: 'Optional. Do NOT fill this in unless you already know the line numbers you need to search. Defaults to Infinity.' },
|
||||
...paginationParam,
|
||||
},
|
||||
},
|
||||
|
|
@ -156,11 +156,22 @@ export const voidTools = {
|
|||
params: {
|
||||
query: { description: `Your query for the search.` },
|
||||
search_in_folder: { description: 'Optional. Leave as blank by default. ONLY fill this in if your previous search with the same query was truncated. Searches descendants of this folder only.' },
|
||||
is_regex: { description: 'Optional. Default is false. Whether query is a regex.' },
|
||||
is_regex: { description: 'Optional. Default is false. Whether the query is a regex.' },
|
||||
...paginationParam,
|
||||
},
|
||||
},
|
||||
|
||||
// add new search_in_file tool
|
||||
search_in_file: {
|
||||
name: 'search_in_file',
|
||||
description: `Returns an array of all the start line numbers where the content appears in the file.`,
|
||||
params: {
|
||||
...uriParam('file'),
|
||||
query: { description: 'The string or regex to search for in the file.' },
|
||||
is_regex: { description: 'Optional. Default is false. Whether the query is a regex.' }
|
||||
}
|
||||
},
|
||||
|
||||
read_lint_errors: {
|
||||
name: 'read_lint_errors',
|
||||
description: `Returns all lint errors on a given file.`,
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ export type ToolCallParams = {
|
|||
'get_dir_tree': { uri: URI },
|
||||
'search_pathnames_only': { query: string, includePattern: string | null, pageNumber: number },
|
||||
'search_for_files': { query: string, isRegex: boolean, searchInFolder: URI | null, pageNumber: number },
|
||||
'search_in_file': { uri: URI, query: string, isRegex: boolean },
|
||||
'read_lint_errors': { uri: URI },
|
||||
// ---
|
||||
'edit_file': { uri: URI, changeDescription: string },
|
||||
|
|
@ -57,6 +58,7 @@ export type ToolResultType = {
|
|||
'get_dir_tree': { str: string, },
|
||||
'search_pathnames_only': { uris: URI[], hasNextPage: boolean },
|
||||
'search_for_files': { uris: URI[], hasNextPage: boolean },
|
||||
'search_in_file': { lines: number[]; },
|
||||
'read_lint_errors': { lintErrors: LintErrorItem[] | null },
|
||||
// ---
|
||||
'edit_file': Promise<{ lintErrors: LintErrorItem[] | null }>,
|
||||
|
|
|
|||
Loading…
Reference in a new issue