mirror of
https://github.com/voideditor/void
synced 2026-05-24 09:58:23 +00:00
tool UI draft
This commit is contained in:
parent
2a876d8efe
commit
bdb897d032
4 changed files with 269 additions and 77 deletions
|
|
@ -48,13 +48,13 @@ export type FileSelection = {
|
|||
export type StagingSelectionItem = CodeSelection | FileSelection
|
||||
|
||||
|
||||
type ToolMessage<T extends ToolName> = {
|
||||
export type ToolMessage<T extends ToolName> = {
|
||||
role: 'tool';
|
||||
name: T; // internal use
|
||||
params: string; // internal use
|
||||
id: string; // apis require this tool use id
|
||||
content: string; // result
|
||||
result: ToolCallReturnType<T>; // text message of result
|
||||
result: ToolCallReturnType[T]; // text message of result
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -430,10 +430,10 @@ class ChatThreadService extends Disposable implements IChatThreadService {
|
|||
|
||||
// 1.
|
||||
let toolResult: Awaited<ReturnType<ToolFns[ToolName]>>
|
||||
let toolResultVal: ToolCallReturnType<ToolName>
|
||||
let toolResultVal: ToolCallReturnType[ToolName]
|
||||
try {
|
||||
toolResult = await this._toolsService.toolFns[toolName](tool.params)
|
||||
toolResultVal = toolResult[0]
|
||||
toolResultVal = toolResult
|
||||
} catch (error) {
|
||||
this._setStreamState(threadId, { error })
|
||||
shouldSendAnotherMessage = false
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import React, { ButtonHTMLAttributes, FormEvent, FormHTMLAttributes, Fragment, K
|
|||
|
||||
|
||||
import { useAccessor, useSidebarState, useChatThreadsState, useChatThreadsStreamState, useUriState, useSettingsState } from '../util/services.js';
|
||||
import { ChatMessage, StagingSelectionItem } from '../../../chatThreadService.js';
|
||||
import { ChatMessage, StagingSelectionItem, ToolMessage } from '../../../chatThreadService.js';
|
||||
|
||||
import { BlockCode } from '../markdown/BlockCode.js';
|
||||
import { ChatMarkdownRender } from '../markdown/ChatMarkdownRender.js';
|
||||
|
|
@ -21,10 +21,11 @@ import { useScrollbarStyles } from '../util/useScrollbarStyles.js';
|
|||
import { VOID_CTRL_L_ACTION_ID } from '../../../actionIDs.js';
|
||||
import { filenameToVscodeLanguage } from '../../../helpers/detectLanguage.js';
|
||||
import { VOID_OPEN_SETTINGS_ACTION_ID } from '../../../voidSettingsPane.js';
|
||||
import { Pencil, X } from 'lucide-react';
|
||||
import { ChevronRight, Pencil, X } from 'lucide-react';
|
||||
import { FeatureName, isFeatureNameDisabled } from '../../../../../../../workbench/contrib/void/common/voidSettingsTypes.js';
|
||||
import { WarningBox } from '../void-settings-tsx/WarningBox.js';
|
||||
import { ChatMessageLocation } from '../../../aiRegexService.js';
|
||||
import { ToolCallReturnType, ToolName } from '../../../../common/toolsService.js';
|
||||
|
||||
|
||||
|
||||
|
|
@ -542,6 +543,146 @@ export const SelectedFiles = (
|
|||
}
|
||||
|
||||
|
||||
type ToolReusltToComponent = { [T in ToolName]: (props: { message: ToolMessage<T> }) => React.ReactNode }
|
||||
interface ToolResultProps {
|
||||
title: string;
|
||||
desc: string;
|
||||
desc2?: number;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const ToolResult = ({
|
||||
title,
|
||||
desc,
|
||||
desc2,
|
||||
children,
|
||||
}: ToolResultProps) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const isDropdown = !!children
|
||||
|
||||
return (
|
||||
<div className="mx-4 select-none">
|
||||
<div className="border border-void-border-3 rounded px-1 py-0.5 bg-void-bg-2-alt">
|
||||
<div
|
||||
className={`flex items-center min-h-[24px] ${isDropdown ? 'cursor-pointer hover:brightness-125 transition-all duration-150' : 'mx-1'}`}
|
||||
onClick={() => children && setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{isDropdown && (
|
||||
<ChevronRight
|
||||
className={`text-void-fg-3 mr-0.5 h-5 w-5 flex-shrink-0 transition-transform duration-100 ease-[cubic-bezier(0.4,0,0.2,1)] ${isExpanded ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center flex-wrap gap-x-2 gap-y-0.5">
|
||||
<span className="text-void-fg-3">{title}</span>
|
||||
<span className="text-void-fg-4 text-xs italic">{`"`}{desc}{`"`}</span>
|
||||
{desc2 !== undefined && (
|
||||
<span className="text-void-fg-4 text-xs">
|
||||
{`(`}{desc2}{` result`}{desc2 !== 1 ? 's' : ''}{`)`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`mt-1 overflow-hidden transition-all duration-200 ease-in-out ${isExpanded ? 'max-h-[500px] opacity-100' : 'max-h-0 opacity-0'}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
const toolResultToComponent: ToolReusltToComponent = {
|
||||
'read_file': ({ message }) => (
|
||||
<ToolResult
|
||||
title="Read file"
|
||||
desc={getBasename(message.result.uri.fsPath)}
|
||||
/>
|
||||
),
|
||||
'list_dir': ({ message }) => (
|
||||
<ToolResult
|
||||
title="Inspected folder"
|
||||
desc={`${getBasename(message.result.rootURI.fsPath)}/`}
|
||||
desc2={message.result.children?.length}
|
||||
>
|
||||
<div className="text-void-fg-2">
|
||||
{message.result.children?.map((item, i) => (
|
||||
<div key={i} className="pl-2">
|
||||
• {item.name}
|
||||
{item.isDirectory && '/'}
|
||||
</div>
|
||||
))}
|
||||
{message.result.hasNextPage && (
|
||||
<div className="pl-2 text-void-fg-3 italic">
|
||||
{message.result.itemsRemaining} more items...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ToolResult>
|
||||
),
|
||||
'pathname_search': ({ message }) => (
|
||||
<ToolResult
|
||||
title="Searched filename"
|
||||
desc={message.result.queryStr}
|
||||
desc2={Array.isArray(message.result.uris) ? message.result.uris.length : 0}
|
||||
>
|
||||
<div className="text-void-fg-2">
|
||||
{Array.isArray(message.result.uris) ?
|
||||
message.result.uris.map((uri, i) => (
|
||||
<div key={i} className="pl-2">
|
||||
<a
|
||||
href={uri.toString()}
|
||||
className="text-void-accent hover:underline"
|
||||
>
|
||||
• {uri.fsPath.split('/').pop()}
|
||||
</a>
|
||||
</div>
|
||||
)) :
|
||||
<div className="pl-2">{message.result.uris}</div>
|
||||
}
|
||||
{message.result.hasNextPage && (
|
||||
<div className="pl-2 text-void-fg-3 italic">
|
||||
More results available...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ToolResult>
|
||||
),
|
||||
'search': ({ message }) => (
|
||||
<ToolResult
|
||||
title="Searched"
|
||||
desc={message.result.queryStr}
|
||||
desc2={Array.isArray(message.result.uris) ? message.result.uris.length : 0}
|
||||
>
|
||||
<div className="text-void-fg-2">
|
||||
{typeof message.result.uris === 'string' ?
|
||||
message.result.uris :
|
||||
message.result.uris.map((uri, i) => (
|
||||
<div key={i} className="pl-2">
|
||||
<a
|
||||
href={uri.toString()}
|
||||
className="text-void-accent hover:underline"
|
||||
>
|
||||
• {uri.fsPath}
|
||||
</a>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
{message.result.hasNextPage && (
|
||||
<div className="pl-2 text-void-fg-3 italic">
|
||||
More results available...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ToolResult>
|
||||
)
|
||||
};
|
||||
|
||||
|
||||
|
||||
type ChatBubbleMode = 'display' | 'edit'
|
||||
const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatMessage, messageIdx?: number, isLoading?: boolean, }) => {
|
||||
|
||||
|
|
@ -695,7 +836,13 @@ const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatM
|
|||
chatbubbleContents = <ChatMarkdownRender string={chatMessage.displayContent ?? ''} chatMessageLocation={chatMessageLocation} />
|
||||
}
|
||||
else if (role === 'tool') {
|
||||
chatbubbleContents = chatMessage.name
|
||||
|
||||
const ToolComponent = toolResultToComponent[chatMessage.name] as ({ message }: { message: any }) => React.ReactNode // ts isnt smart enough to deal with the types here...
|
||||
|
||||
chatbubbleContents = <ToolComponent message={chatMessage} />
|
||||
|
||||
console.log('tool result:', chatMessage.name, chatMessage.params, chatMessage.result)
|
||||
|
||||
}
|
||||
|
||||
return <div
|
||||
|
|
|
|||
|
|
@ -29,16 +29,23 @@ module.exports = {
|
|||
colors: {
|
||||
"void-bg-1": "var(--vscode-input-background)",
|
||||
"void-bg-2": "var(--vscode-sideBar-background)",
|
||||
"void-bg-2-alt": "color-mix(in srgb, var(--vscode-sideBar-background) 30%, var(--vscode-editor-background) 70%)",
|
||||
"void-bg-3": "var(--vscode-editor-background)",
|
||||
|
||||
|
||||
"void-fg-1": "var(--vscode-editor-foreground)",
|
||||
"void-fg-2": "var(--vscode-input-foreground)",
|
||||
"void-fg-3": "var(--vscode-input-placeholderForeground)",
|
||||
// "void-fg-4": "var(--vscode-tab-inactiveForeground)",
|
||||
"void-fg-4": "var(--vscode-list-deemphasizedForeground)",
|
||||
|
||||
|
||||
"void-warning": "var(--vscode-charts-yellow)",
|
||||
|
||||
"void-border-1": "var(--vscode-commandCenter-activeBorder)",
|
||||
"void-border-2": "var(--vscode-commandCenter-border)",
|
||||
"void-border-3": "var(--vscode-commandCenter-inactiveBorder)",
|
||||
"void-border-3": "var(--vscode-settings-sashBorder)",
|
||||
|
||||
|
||||
vscode: {
|
||||
|
|
|
|||
|
|
@ -92,69 +92,93 @@ export const toolNames = Object.keys(voidTools) as ToolName[]
|
|||
export type ToolParamNames<T extends ToolName> = keyof typeof voidTools[T]['params']
|
||||
export type ToolParamsObj<T extends ToolName> = { [paramName in ToolParamNames<T>]: unknown }
|
||||
|
||||
export type ToolCallReturnType = {
|
||||
'read_file': { uri: URI, fileContents: string, hasNextPage: boolean },
|
||||
'list_dir': { rootURI: URI, children: DirectoryItem[] | null, hasNextPage: boolean, hasPrevPage: boolean, itemsRemaining: number },
|
||||
'pathname_search': { queryStr: string, uris: URI[] | string, hasNextPage: boolean },
|
||||
'search': { queryStr: string, uris: URI[] | string, hasNextPage: boolean }
|
||||
'create_file': {}
|
||||
}
|
||||
|
||||
export type ToolCallReturnType<T extends ToolName>
|
||||
= T extends 'read_file' ? string
|
||||
: T extends 'list_dir' ? string
|
||||
: T extends 'pathname_search' ? string | URI[]
|
||||
: T extends 'search' ? string | URI[]
|
||||
: T extends 'create_file' ? string
|
||||
: never
|
||||
type DirectoryItem = {
|
||||
name: string;
|
||||
isDirectory: boolean;
|
||||
isSymbolicLink: boolean;
|
||||
}
|
||||
|
||||
export type ToolFns = { [T in ToolName]: (p: string) => Promise<[ToolCallReturnType<T>, boolean]> }
|
||||
export type ToolResultToString = { [T in ToolName]: (result: [ToolCallReturnType<T>, boolean]) => string }
|
||||
export type ToolFns = { [T in ToolName]: (p: string) => Promise<ToolCallReturnType[T]> }
|
||||
export type ToolResultToString = { [T in ToolName]: (result: ToolCallReturnType[T]) => string }
|
||||
|
||||
|
||||
// pagination info
|
||||
const MAX_FILE_CHARS_PAGE = 50_000
|
||||
const MAX_CHILDREN_URIs_PAGE = 500
|
||||
|
||||
const MAX_DEPTH = 1
|
||||
async function generateDirectoryTreeMd(fileService: IFileService, rootURI: URI, pageNumber: number): Promise<[string, boolean]> {
|
||||
let output = '';
|
||||
|
||||
const indentation = (depth: number, isLast: boolean): string => {
|
||||
if (depth === 0) return '';
|
||||
return `${'| '.repeat(depth - 1)}${isLast ? '└── ' : '├── '}`;
|
||||
};
|
||||
|
||||
let hasNextPage = false
|
||||
|
||||
async function traverseChildren(uri: URI, depth: number, isLast: boolean) {
|
||||
const stat = await fileService.resolve(uri, { resolveMetadata: false });
|
||||
|
||||
// we might want to say where symlink links to
|
||||
if (depth === 0 && pageNumber !== 1)
|
||||
output += ''
|
||||
else
|
||||
output += `${indentation(depth, isLast)}${stat.name}${stat.isDirectory ? '/' : ''}${stat.isSymbolicLink ? ` (symbolic link)` : ''}\n`;
|
||||
|
||||
// list children
|
||||
const originalChildrenLength = stat.children?.length ?? 0
|
||||
const fromChildIdx = MAX_CHILDREN_URIs_PAGE * (pageNumber - 1)
|
||||
const toChildIdx = MAX_CHILDREN_URIs_PAGE * pageNumber - 1 // INCLUSIVE
|
||||
const listChildren = stat.children?.slice(fromChildIdx, toChildIdx + 1) ?? [];
|
||||
|
||||
if (!stat.isDirectory) return;
|
||||
|
||||
if (listChildren.length === 0) return
|
||||
if (depth === MAX_DEPTH) return // right now MAX_DEPTH=1 to make pagination work nicely
|
||||
|
||||
for (let i = 0; i < Math.min(listChildren.length, MAX_CHILDREN_URIs_PAGE); i++) {
|
||||
await traverseChildren(listChildren[i].resource, depth + 1, i === listChildren.length - 1);
|
||||
}
|
||||
const nCutoffResults = (originalChildrenLength - 1) - toChildIdx
|
||||
if (nCutoffResults >= 1) {
|
||||
output += `${indentation(depth + 1, true)}(${nCutoffResults} results remaining...)\n`
|
||||
hasNextPage = true
|
||||
}
|
||||
|
||||
const computeDirectoryResult = async (
|
||||
fileService: IFileService,
|
||||
rootURI: URI,
|
||||
pageNumber: number = 1
|
||||
): Promise<ToolCallReturnType['list_dir']> => {
|
||||
const stat = await fileService.resolve(rootURI, { resolveMetadata: false });
|
||||
if (!stat.isDirectory) {
|
||||
return { rootURI, children: null, hasNextPage: false, hasPrevPage: false, itemsRemaining: 0 };
|
||||
}
|
||||
|
||||
await traverseChildren(rootURI, 0, false);
|
||||
const originalChildrenLength = stat.children?.length ?? 0;
|
||||
const fromChildIdx = MAX_CHILDREN_URIs_PAGE * (pageNumber - 1);
|
||||
const toChildIdx = MAX_CHILDREN_URIs_PAGE * pageNumber - 1; // INCLUSIVE
|
||||
const listChildren = stat.children?.slice(fromChildIdx, toChildIdx + 1) ?? [];
|
||||
|
||||
const children: DirectoryItem[] = listChildren.map(child => ({
|
||||
name: child.name,
|
||||
isDirectory: child.isDirectory,
|
||||
isSymbolicLink: child.isSymbolicLink || false
|
||||
}));
|
||||
|
||||
const hasNextPage = (originalChildrenLength - 1) > toChildIdx;
|
||||
const hasPrevPage = pageNumber > 1;
|
||||
const itemsRemaining = Math.max(0, originalChildrenLength - (toChildIdx + 1));
|
||||
|
||||
return {
|
||||
rootURI,
|
||||
children,
|
||||
hasNextPage,
|
||||
hasPrevPage,
|
||||
itemsRemaining
|
||||
};
|
||||
};
|
||||
|
||||
const directoryResultToString = (result: ToolCallReturnType['list_dir']): string => {
|
||||
if (!result.children) {
|
||||
return `Error: ${result.rootURI} is not a directory`;
|
||||
}
|
||||
|
||||
let output = '';
|
||||
const entries = result.children;
|
||||
|
||||
if (!result.hasPrevPage) {
|
||||
output += `${result.rootURI}\n`;
|
||||
}
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
const isLast = i === entries.length - 1 && !result.hasNextPage;
|
||||
const prefix = isLast ? '└── ' : '├── ';
|
||||
|
||||
output += `${prefix}${entry.name}${entry.isDirectory ? '/' : ''}${entry.isSymbolicLink ? ' (symbolic link)' : ''}\n`;
|
||||
}
|
||||
|
||||
if (result.hasNextPage) {
|
||||
output += `└── (${result.itemsRemaining} results remaining...)\n`;
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
|
||||
|
||||
return [output, hasNextPage]
|
||||
}
|
||||
|
||||
|
||||
const validateJSON = (s: string): { [s: string]: unknown } => {
|
||||
|
|
@ -217,6 +241,8 @@ export class ToolsService implements IToolsService {
|
|||
|
||||
this.toolFns = {
|
||||
read_file: async (s: string) => {
|
||||
console.log('read_file')
|
||||
|
||||
const o = validateJSON(s)
|
||||
const { uri: uriStr, pageNumber: pageNumberUnknown } = o
|
||||
|
||||
|
|
@ -227,22 +253,30 @@ export class ToolsService implements IToolsService {
|
|||
|
||||
const fromIdx = MAX_FILE_CHARS_PAGE * (pageNumber - 1)
|
||||
const toIdx = MAX_FILE_CHARS_PAGE * pageNumber - 1
|
||||
let fileContents = readFileContents.slice(fromIdx, toIdx + 1) // paginate
|
||||
const fileContents = readFileContents.slice(fromIdx, toIdx + 1) || '(empty)' // paginate
|
||||
const hasNextPage = (readFileContents.length - 1) - toIdx >= 1
|
||||
|
||||
return [fileContents || '(empty)', hasNextPage]
|
||||
|
||||
console.log('read_file result:', fileContents)
|
||||
|
||||
|
||||
return { uri, fileContents, hasNextPage }
|
||||
},
|
||||
list_dir: async (s: string) => {
|
||||
console.log('list_dir')
|
||||
const o = validateJSON(s)
|
||||
const { uri: uriStr, pageNumber: pageNumberUnknown } = o
|
||||
|
||||
const uri = validateURI(uriStr)
|
||||
const pageNumber = validatePageNum(pageNumberUnknown)
|
||||
|
||||
const [treeStr, hasNextPage] = await generateDirectoryTreeMd(fileService, uri, pageNumber)
|
||||
return [treeStr, hasNextPage]
|
||||
const dirResult = await computeDirectoryResult(fileService, uri, pageNumber)
|
||||
console.log('list_dir result:', dirResult)
|
||||
|
||||
return dirResult
|
||||
},
|
||||
pathname_search: async (s: string) => {
|
||||
console.log('pathname_search')
|
||||
const o = validateJSON(s)
|
||||
const { query: queryUnknown, pageNumber: pageNumberUnknown } = o
|
||||
|
||||
|
|
@ -254,15 +288,18 @@ export class ToolsService implements IToolsService {
|
|||
|
||||
const fromIdx = MAX_CHILDREN_URIs_PAGE * (pageNumber - 1)
|
||||
const toIdx = MAX_CHILDREN_URIs_PAGE * pageNumber - 1
|
||||
const URIs = data.results
|
||||
const uris = data.results
|
||||
.slice(fromIdx, toIdx + 1) // paginate
|
||||
.map(({ resource, results }) => resource)
|
||||
|
||||
const hasNextPage = (data.results.length - 1) - toIdx >= 1
|
||||
console.log('pathname_search result:', uris)
|
||||
|
||||
return [URIs, hasNextPage]
|
||||
return { queryStr, uris, hasNextPage }
|
||||
},
|
||||
search: async (s: string) => {
|
||||
console.log('search')
|
||||
|
||||
const o = validateJSON(s)
|
||||
const { query: queryUnknown, pageNumber: pageNumberUnknown } = o
|
||||
|
||||
|
|
@ -274,35 +311,37 @@ export class ToolsService implements IToolsService {
|
|||
|
||||
const fromIdx = MAX_CHILDREN_URIs_PAGE * (pageNumber - 1)
|
||||
const toIdx = MAX_CHILDREN_URIs_PAGE * pageNumber - 1
|
||||
const URIs = data.results
|
||||
const uris = data.results
|
||||
.slice(fromIdx, toIdx + 1) // paginate
|
||||
.map(({ resource, results }) => resource)
|
||||
|
||||
const hasNextPage = (data.results.length - 1) - toIdx >= 1
|
||||
|
||||
return [URIs, hasNextPage]
|
||||
console.log('search result:', uris)
|
||||
|
||||
return { queryStr, uris, hasNextPage }
|
||||
},
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
const nextPageStr = (hasNextPage: boolean) => hasNextPage ? '\n\n(more on next page...)' : ''
|
||||
|
||||
this.toolResultToString = {
|
||||
read_file: ([fileContents, hasNextPage]) => {
|
||||
return fileContents + nextPageStr(hasNextPage)
|
||||
read_file: (result) => {
|
||||
return nextPageStr(result.hasNextPage)
|
||||
},
|
||||
list_dir: ([dirTreeStr, hasNextPage]) => {
|
||||
return dirTreeStr + nextPageStr(hasNextPage)
|
||||
list_dir: (result) => {
|
||||
const dirTreeStr = directoryResultToString(result)
|
||||
return dirTreeStr + nextPageStr(result.hasNextPage)
|
||||
},
|
||||
pathname_search: ([URIs, hasNextPage]) => {
|
||||
if (typeof URIs === 'string') return URIs
|
||||
return URIs.map(uri => uri.fsPath).join('\n') + nextPageStr(hasNextPage)
|
||||
pathname_search: (result) => {
|
||||
if (typeof result.uris === 'string') return result.uris
|
||||
return result.uris.map(uri => uri.fsPath).join('\n') + nextPageStr(result.hasNextPage)
|
||||
},
|
||||
search: ([URIs, hasNextPage]) => {
|
||||
if (typeof URIs === 'string') return URIs
|
||||
return URIs.map(uri => uri.fsPath).join('\n') + nextPageStr(hasNextPage)
|
||||
search: (result) => {
|
||||
if (typeof result.uris === 'string') return result.uris
|
||||
return result.uris.map(uri => uri.fsPath).join('\n') + nextPageStr(result.hasNextPage)
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -314,4 +353,3 @@ export class ToolsService implements IToolsService {
|
|||
}
|
||||
|
||||
registerSingleton(IToolsService, ToolsService, InstantiationType.Eager);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue