From 2776200edf1c82ee34aa7c60105fff073c63d24d Mon Sep 17 00:00:00 2001 From: Anirban Kar Date: Thu, 14 Nov 2024 05:34:55 +0530 Subject: [PATCH] feat(code-template): code template with AI tool Calling Feature added with optin feature --- app/components/chat/BaseChat.tsx | 19 +++++-- app/components/chat/Chat.client.tsx | 9 +++- app/components/chat/Messages.client.tsx | 16 ++++-- app/components/chat/ToolManager.tsx | 32 ++++++++++++ app/components/chat/ToolMessage.tsx | 14 ++++-- app/components/ui/Popover.tsx | 47 +++++++++++++++++ app/components/ui/ToggleSwitch.tsx | 55 ++++++++++++++++++++ app/lib/.server/llm/stream-text.ts | 3 +- app/lib/stores/tool.ts | 67 +++++++++++++++++-------- app/lib/stores/workbench.ts | 16 ++---- app/routes/api.chat.ts | 7 ++- app/utils/types.ts | 10 ++++ 12 files changed, 244 insertions(+), 51 deletions(-) create mode 100644 app/components/chat/ToolManager.tsx create mode 100644 app/components/ui/Popover.tsx create mode 100644 app/components/ui/ToggleSwitch.tsx diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx index 2a266d175..b0fef7380 100644 --- a/app/components/chat/BaseChat.tsx +++ b/app/components/chat/BaseChat.tsx @@ -15,6 +15,7 @@ import { APIKeyManager } from './APIKeyManager'; import Cookies from 'js-cookie'; import styles from './BaseChat.module.scss'; +import { ToolManager } from './ToolManager'; const EXAMPLE_PROMPTS = [ { text: 'Build a todo app in React using Tailwind' }, @@ -92,6 +93,8 @@ interface BaseChatProps { handleInputChange?: (event: React.ChangeEvent) => void; enhancePrompt?: () => void; addToolResult?: ({ toolCallId, result }: { toolCallId: string; result: any }) => void; + toolConfig: IToolsConfig; + onToolConfigChange?: (val: IToolsConfig) => void; } export const BaseChat = React.forwardRef( @@ -116,6 +119,8 @@ export const BaseChat = React.forwardRef( enhancePrompt, handleStop, addToolResult, + toolConfig, + onToolConfigChange, }, ref, ) => { @@ -208,11 +213,15 @@ export const BaseChat = React.forwardRef( setProvider={setProvider} providerList={providerList} /> - updateApiKey(provider, key)} - /> +
+ + + updateApiKey(provider, key)} + /> +
>({}); @@ -91,7 +94,9 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp api: '/api/chat', body: { apiKeys, + toolEnabled: toolConfig.enabled, }, + maxSteps: 3, onError: (error) => { logger.error('Request failed\n\n', error); toast.error('There was an error processing your request'); @@ -104,7 +109,7 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp if (toolCall.toolName == 'askForConfirmation') return; logger.debug('Calling Tool:', toolCall); try { - let result = await workbenchStore.handleToolCall({ + let result = await toolStore.handleToolCall({ toolName: toolCall.toolName, args: toolCall.args, toolCallId: toolCall.toolCallId, @@ -276,6 +281,8 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp handleInputChange={handleInputChange} handleStop={abort} addToolResult={addToolResult} + toolConfig={toolConfig} + onToolConfigChange={(config) => toolStore.setConfig(config)} messages={messages.map((message, i) => { if (message.role === 'user') { return message; diff --git a/app/components/chat/Messages.client.tsx b/app/components/chat/Messages.client.tsx index d262ba359..0c1b60ff2 100644 --- a/app/components/chat/Messages.client.tsx +++ b/app/components/chat/Messages.client.tsx @@ -1,5 +1,5 @@ import type { JSONValue, Message, ToolInvocation } from 'ai'; -import React from 'react'; +import React, { Fragment } from 'react'; import { classNames } from '~/utils/classNames'; import { AssistantMessage } from './AssistantMessage'; import { UserMessage } from './UserMessage'; @@ -24,15 +24,23 @@ export const Messages = React.forwardRef((props: const isUserMessage = role === 'user'; const isFirst = index === 0; const isLast = index === messages.length - 1; + + // checking for annotated message marked as "hidden" if (message.annotations) { let isHidden = message.annotations.find((annotation: JSONValue) => { if (typeof annotation !== 'object' || typeof annotation?.length === 'number') return false; let object = annotation as any; return object.visibility === 'hidden'; }); - console.log('isHidden', isHidden, message); - - if (isHidden) return <>; + if (isHidden) return ; + } + //hide confirmation message that has been answered + if ( + message.toolInvocations?.length == 1 && + message.toolInvocations[0].toolName === 'askForConfirmation' && + (message.toolInvocations[0] as any).result + ) { + return ; } return (
void; +} + +export function ToolManager({ toolConfig, onConfigChange }: ToolManagerProps) { + return ( + <> + {toolConfig && ( +
+
+ + { + onConfigChange?.({ + enabled: e, + config: toolConfig.config, + }); + }} + /> +
+
+ )} + + ); +} diff --git a/app/components/chat/ToolMessage.tsx b/app/components/chat/ToolMessage.tsx index 285810acc..80bce311c 100644 --- a/app/components/chat/ToolMessage.tsx +++ b/app/components/chat/ToolMessage.tsx @@ -15,7 +15,8 @@ const AskForConfirmation = (props: { addToolResult?: ({ toolCallId, result }: { toolCallId: string; result: any }) => void; }) => { const toolCallId = props.toolInvocation.toolCallId; - const addResult = (result: string) => (props.addToolResult ? props.addToolResult({ toolCallId, result }) : null); + const addResult = (result: string) => + props.addToolResult ? props.addToolResult({ toolCallId, result: result }) : null; return (
@@ -53,8 +54,11 @@ export function ToolMessage({ content, data: result, addToolResult }: ToolMessag result: (content as any).result, }); }, [content]); - if (data?.name == 'askForConfirmation' && (data.state == 'result' || data.state == 'call')) { - return ; + if (data?.name == 'askForConfirmation') { + if (!data.result) { + return ; + } + return <>; } return (
@@ -86,7 +90,9 @@ export function ToolMessage({ content, data: result, addToolResult }: ToolMessag )} {/* //results in dimmed tone */} {data.state == 'result' && ( -
Output: {(content as any).result}
+
+ Output: {`${(content as any).result || ''}`.split('---')[0]} +
)}
diff --git a/app/components/ui/Popover.tsx b/app/components/ui/Popover.tsx new file mode 100644 index 000000000..f1947ea5f --- /dev/null +++ b/app/components/ui/Popover.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import * as PopoverPrimitive from '@radix-ui/react-popover'; + +const Popover = PopoverPrimitive.Root; +const PopoverTrigger = PopoverPrimitive.Trigger; + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( + +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverTrigger, PopoverContent }; diff --git a/app/components/ui/ToggleSwitch.tsx b/app/components/ui/ToggleSwitch.tsx new file mode 100644 index 000000000..1e68c7b50 --- /dev/null +++ b/app/components/ui/ToggleSwitch.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import * as SwitchPrimitives from '@radix-ui/react-switch'; + +const ToggleSwitch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +ToggleSwitch.displayName = SwitchPrimitives.Root.displayName; + +export { ToggleSwitch }; diff --git a/app/lib/.server/llm/stream-text.ts b/app/lib/.server/llm/stream-text.ts index 4554d7f1b..e5500c13d 100644 --- a/app/lib/.server/llm/stream-text.ts +++ b/app/lib/.server/llm/stream-text.ts @@ -76,8 +76,7 @@ export function streamText( ...options, // toolChoice: messages.length == 1 ? { type: 'tool', toolName: 'selectCodeTemplate' } : 'none', toolChoice: 'auto', - tools: tools, experimental_toolCallStreaming: true, - maxSteps: 2 + maxSteps: 3 }); } diff --git a/app/lib/stores/tool.ts b/app/lib/stores/tool.ts index 65c683090..e8238ff44 100644 --- a/app/lib/stores/tool.ts +++ b/app/lib/stores/tool.ts @@ -1,21 +1,36 @@ -import type { WebContainer, FileNode } from '@webcontainer/api'; -import type { FileMap, FilesStore } from './files'; +import type { WebContainer } from '@webcontainer/api'; import { TEMPLATE_LIST } from '~/utils/constants'; import * as nodePath from 'node:path'; -import type { EditorStore } from './editor'; -import type { WorkbenchStore } from './workbench'; - - - +import { workbenchStore } from './workbench'; +import { webcontainer } from '../webcontainer'; +import { map, type MapStore } from 'nanostores'; +import type { IToolsConfig } from '~/utils/types'; +import Cookies from 'js-cookie'; export class ToolStore { #webcontainer: Promise; - #workbench: WorkbenchStore; - #editorStore: EditorStore; - constructor(webcontainerPromise: Promise, workbench: WorkbenchStore, editorStore: EditorStore) { + config: MapStore = map({ + enabled: false, + config: {} + }) + constructor(webcontainerPromise: Promise) { this.#webcontainer = webcontainerPromise; - this.#workbench = workbench; - this.#editorStore = editorStore; + let configString = Cookies.get('toolsConfig'); + if (configString) { + try { + let config = JSON.parse(configString); + this.config.set(config); + } catch (error) { + console.error('Error parsing tools config:', error); + } + } + } + enableTools(enable: boolean) { + this.config.setKey('enabled', enable); + } + setConfig(config: IToolsConfig) { + this.config.set(config); + Cookies.set('toolsConfig', JSON.stringify(config)); } async handleToolCall(payload: { toolName: string, args: any, toolCallId: string; }): Promise { @@ -59,18 +74,21 @@ export class ToolStore { console.log("Writing to file", fullPath); await webcontainer.fs.writeFile(file.path, file.content); - await this.#workbench.files.setKey(fullPath, { type: 'file', content: '', isBinary: false }) - await this.#editorStore.updateFile(fullPath, file.content) - await this.#workbench.saveFile(file.path) - // await this.#editorStore.setSelectedFile(file.path) - - + await workbenchStore.files.setKey(fullPath, { type: 'file', content: '', isBinary: false }) + await workbenchStore.updateFile(fullPath, file.content) + await workbenchStore.saveFile(file.path) } + return this.generateFormattedResult(`template imported successfully`, ` + here is the imported content, + these files are loaded into the bolt. to not write them again, if it don't require changes + you only need to write the files that needs changing + + ${JSON.stringify(files, null, 2)} + `) } catch (error) { console.error('error importing template', error); return 'error fetching template'; } - return 'templace imported successfully'; } private async getGitHubRepoContent(repoName: string, path: string = ''): Promise<{ name: string, path: string, content: string }[]> { @@ -140,4 +158,13 @@ export class ToolStore { throw error; } } -} \ No newline at end of file + private generateFormattedResult(uiResult: string, aiResult?: string) { + return ` + ${uiResult} + --- + ${aiResult || ""} + ` + } +} + +export const toolStore = new ToolStore(webcontainer); \ No newline at end of file diff --git a/app/lib/stores/workbench.ts b/app/lib/stores/workbench.ts index 733774542..544112812 100644 --- a/app/lib/stores/workbench.ts +++ b/app/lib/stores/workbench.ts @@ -13,8 +13,6 @@ import JSZip from 'jszip'; import { saveAs } from 'file-saver'; import { Octokit, type RestEndpointMethodTypes } from "@octokit/rest"; import * as nodePath from 'node:path'; -import type { WebContainerProcess } from '@webcontainer/api'; -import { ToolStore } from './tool'; export interface ArtifactState { id: string; @@ -34,7 +32,6 @@ export class WorkbenchStore { #filesStore = new FilesStore(webcontainer); #editorStore = new EditorStore(this.#filesStore); #terminalStore = new TerminalStore(webcontainer); - #toolStore = new ToolStore(webcontainer, this, this.#editorStore); artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({}); showWorkbench: WritableAtom = import.meta.hot?.data.showWorkbench ?? atom(false); @@ -112,7 +109,9 @@ export class WorkbenchStore { } } } - + updateFile(filePath: string, newContent: string) { + this.#editorStore.updateFile(filePath, newContent); + } setShowWorkbench(show: boolean) { this.showWorkbench.set(show); } @@ -471,15 +470,6 @@ export class WorkbenchStore { console.error('Error pushing to GitHub:', error instanceof Error ? error.message : String(error)); } } - - async handleToolCall(payload: { toolName: string, args: any, toolCallId: string; }): Promise { - try { - return await this.#toolStore.handleToolCall(payload); - } catch (error: any) { - console.error('Error calling tool:', error); - return 'error:' + error.message || 'error'; - } - } } export const workbenchStore = new WorkbenchStore(); diff --git a/app/routes/api.chat.ts b/app/routes/api.chat.ts index 79cb8edbe..aefab033d 100644 --- a/app/routes/api.chat.ts +++ b/app/routes/api.chat.ts @@ -5,6 +5,7 @@ import { MAX_RESPONSE_SEGMENTS, MAX_TOKENS } from '~/lib/.server/llm/constants'; import { CONTINUE_PROMPT } from '~/lib/.server/llm/prompts'; import { streamText, type Messages, type StreamingOptions } from '~/lib/.server/llm/stream-text'; import SwitchableStream from '~/lib/.server/llm/switchable-stream'; +import tools from '~/lib/.server/llm/tools'; import { z } from 'zod'; export async function action(args: ActionFunctionArgs) { @@ -12,9 +13,10 @@ export async function action(args: ActionFunctionArgs) { } async function chatAction({ context, request }: ActionFunctionArgs) { - const { messages, apiKeys } = await request.json<{ + const { messages, apiKeys, toolEnabled } = await request.json<{ messages: Messages, apiKeys: Record + toolEnabled: boolean }>(); const stream = new SwitchableStream(); @@ -43,7 +45,8 @@ async function chatAction({ context, request }: ActionFunctionArgs) { const result = await streamText(messages, context.cloudflare.env, options, apiKeys); return stream.switchSource(result.toDataStream()); - } + }, + tools: toolEnabled ? tools : undefined, }; const result = await streamText(messages, context.cloudflare.env, options, apiKeys); diff --git a/app/utils/types.ts b/app/utils/types.ts index b0cdd5e5f..fb9b77234 100644 --- a/app/utils/types.ts +++ b/app/utils/types.ts @@ -32,4 +32,14 @@ export interface TemplateInfo { name: string; label: string; githubRepo: string; +} + +export interface IToolsConfig { + enabled: boolean; + //this section will be usefull for future features + config: Record } \ No newline at end of file