Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Studio: Add chat context #252

Merged
merged 11 commits into from
Jun 19, 2024
11 changes: 7 additions & 4 deletions src/components/content-tab-assistant.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import remarkGfm from 'remark-gfm';
import { useAssistant, Message as MessageType } from '../hooks/use-assistant';
import { useAssistantApi } from '../hooks/use-assistant-api';
import { useAuth } from '../hooks/use-auth';
import { useChatContext } from '../hooks/use-chat-context';
import { useFetchWelcomeMessages } from '../hooks/use-fetch-welcome-messages';
import { useOffline } from '../hooks/use-offline';
import { usePromptUsage } from '../hooks/use-prompt-usage';
Expand Down Expand Up @@ -276,6 +277,7 @@ const UnauthenticatedView = ( { onAuthenticate }: { onAuthenticate: () => void }
);

export function ContentTabAssistant( { selectedSite }: ContentTabAssistantProps ) {
const currentSiteChatContext = useChatContext();
const { messages, addMessage, chatId, clearMessages } = useAssistant( selectedSite.name );
const { userCanSendMessage } = usePromptUsage();
const { fetchAssistant, isLoading: isAssistantThinking } = useAssistantApi( selectedSite.name );
Expand All @@ -300,10 +302,11 @@ export function ContentTabAssistant( { selectedSite }: ContentTabAssistantProps
addMessage( chatMessage, 'user', chatId );
setInput( '' );
try {
const { message, chatId: fetchedChatId } = await fetchAssistant( chatId, [
...messages,
{ content: chatMessage, role: 'user' },
] );
const { message, chatId: fetchedChatId } = await fetchAssistant(
chatId,
[ ...messages, { content: chatMessage, role: 'user' } ],
currentSiteChatContext
);
if ( message ) {
addMessage( message, 'assistant', chatId ?? fetchedChatId );
}
Expand Down
5 changes: 4 additions & 1 deletion src/components/root.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ChatProvider } from '../hooks/use-chat-context';
import { InstalledAppsProvider } from '../hooks/use-check-installed-apps';
import { OnboardingProvider } from '../hooks/use-onboarding';
import { PromptUsageProvider } from '../hooks/use-prompt-usage';
Expand All @@ -20,7 +21,9 @@ const Root = () => {
<InstalledAppsProvider>
<OnboardingProvider>
<PromptUsageProvider>
<App />
<ChatProvider>
<App />
</ChatProvider>
</PromptUsageProvider>
</OnboardingProvider>
</InstalledAppsProvider>
Expand Down
24 changes: 22 additions & 2 deletions src/hooks/use-assistant-api.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,43 @@
import { useCallback, useState } from 'react';
import { Message } from './use-assistant';
import { useAuth } from './use-auth';
import { ChatContextType } from './use-chat-context';
import { usePromptUsage } from './use-prompt-usage';

const contextMapper = ( context?: ChatContextType ) => {
if ( ! context ) {
return {};
}
return {
current_url: context.currentURL,
number_of_sites: context.numberOfSites,
wp_version: context.wpVersion,
php_version: context.phpVersion,
plugins: context.pluginList,
themes: context.themeList,
current_theme: context.themeName,
is_block_theme: context.isBlockTheme,
ide: context.availableEditors,
site_name: context.siteName,
os: context.os,
};
};

export function useAssistantApi( selectedSiteId: string ) {
const { client } = useAuth();
const [ isLoading, setIsLoading ] = useState< Record< string, boolean > >( {} );
const { updatePromptUsage } = usePromptUsage();

const fetchAssistant = useCallback(
async ( chatId: string | undefined, messages: Message[] ) => {
async ( chatId: string | undefined, messages: Message[], context?: ChatContextType ) => {
if ( ! client ) {
throw new Error( 'WPcom client not initialized' );
}
setIsLoading( ( prev ) => ( { ...prev, [ selectedSiteId ]: true } ) );
const body = {
messages,
chat_id: chatId,
context: [],
context: contextMapper( context ),
};
let response;
let headers;
Expand Down
184 changes: 184 additions & 0 deletions src/hooks/use-chat-context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import React, {
createContext,
useContext,
useMemo,
useState,
useEffect,
useCallback,
ReactNode,
} from 'react';
import { DEFAULT_PHP_VERSION } from '../../vendor/wp-now/src/constants';
import { getIpcApi } from '../lib/get-ipc-api';
import { useCheckInstalledApps } from './use-check-installed-apps';
import { useGetWpVersion } from './use-get-wp-version';
import { useSiteDetails } from './use-site-details';
import { useThemeDetails } from './use-theme-details';
import { useWindowListener } from './use-window-listener';

export interface ChatContextType {
currentURL: string;
pluginList: string[];
themeList: string[];
numberOfSites: number;
themeName?: string;
wpVersion: string;
phpVersion: string;
isBlockTheme?: boolean;
os: string;
availableEditors: string[];
siteName?: string;
}
const ChatContext = createContext< ChatContextType >( {
currentURL: '',
pluginList: [],
themeList: [],
numberOfSites: 0,
themeName: '',
phpVersion: '',
isBlockTheme: false,
wpVersion: '',
availableEditors: [] as string[],
os: '',
siteName: '',
} );

interface ChatProviderProps {
children: ReactNode;
}

const parseWpCliOutput = ( stdout: string, defaultValue: string[] ): string[] => {
try {
const data = JSON.parse( stdout );
return data?.map( ( item: { name: string } ) => item.name ) || [];
} catch ( error ) {
console.error( error );
}
return defaultValue;
};

export const ChatProvider: React.FC< ChatProviderProps > = ( { children } ) => {
const [ initialLoad, setInitialLoad ] = useState< Record< string, boolean > >( {} );
const installedApps = useCheckInstalledApps();
const { data: sites, loadingSites, selectedSite } = useSiteDetails();
const wpVersion = useGetWpVersion( selectedSite || ( {} as SiteDetails ) );
const [ pluginsList, setPluginsList ] = useState< Record< string, string[] > >( {} );
const [ themesList, setThemesList ] = useState< Record< string, string[] > >( {} );
const numberOfSites = sites?.length || 0;
const sitePath = selectedSite?.path || '';
const sitePort = selectedSite?.port || '';

const { selectedThemeDetails: themeDetails } = useThemeDetails();

const availableEditors = Object.keys( installedApps ).filter( ( app ) => {
return installedApps[ app as keyof InstalledApps ];
} );

const fetchPluginList = useCallback( async ( path: string ) => {
const { stdout, stderr } = await getIpcApi().executeWPCLiInline( {
projectPath: path,
args: 'plugin list --format=json --status=active',
} );
if ( stderr ) {
return [];
}
return parseWpCliOutput( stdout, [] );
}, [] );

const fetchThemeList = useCallback( async ( path: string ) => {
const { stdout, stderr } = await getIpcApi().executeWPCLiInline( {
projectPath: path,
args: 'theme list --format=json',
} );
if ( stderr ) {
return [];
}
return parseWpCliOutput( stdout, [] );
}, [] );

useEffect( () => {
let isCurrent = true;
const run = async () => {
const result = await Promise.all( [
fetchPluginList( sitePath ),
fetchThemeList( sitePath ),
] );
if ( isCurrent && selectedSite?.id ) {
setInitialLoad( ( prev ) => ( { ...prev, [ selectedSite.id ]: true } ) );
setPluginsList( ( prev ) => ( { ...prev, [ selectedSite.id ]: result[ 0 ] } ) );
setThemesList( ( prev ) => ( { ...prev, [ selectedSite.id ]: result[ 1 ] } ) );
}
};
if (
selectedSite &&
! loadingSites &&
! initialLoad[ selectedSite.id ] &&
isCurrent &&
! pluginsList[ selectedSite.id ] &&
! themesList[ selectedSite.id ]
) {
run();
}
return () => {
isCurrent = false;
};
}, [
fetchPluginList,
fetchThemeList,
initialLoad,
loadingSites,
pluginsList,
selectedSite,
sites,
themesList,
sitePath,
] );

useWindowListener( 'focus', async () => {
// When the window is focused, we need to kick off a request to refetch the theme details, if server is running.
if ( ! selectedSite?.id || selectedSite.running === false ) {
kozer marked this conversation as resolved.
Show resolved Hide resolved
return;
}
const plugins = await fetchPluginList( sitePath );
const themes = await fetchThemeList( sitePath );
setPluginsList( ( prev ) => ( { ...prev, [ selectedSite.id ]: plugins } ) );
setThemesList( ( prev ) => ( { ...prev, [ selectedSite.id ]: themes } ) );
} );

const contextValue = useMemo( () => {
return {
numberOfSites,
themeList: selectedSite?.id ? themesList[ selectedSite.id ] || [] : [],
pluginList: selectedSite?.id ? pluginsList[ selectedSite.id ] || [] : [],
wpVersion,
phpVersion: selectedSite?.phpVersion ?? DEFAULT_PHP_VERSION,
currentURL: `http://localhost:${ sitePort }`,
themeName: themeDetails?.name,
isBlockTheme: themeDetails?.isBlockTheme,
availableEditors,
siteName: selectedSite?.name,
os: window.appGlobals.platform,
};
}, [
numberOfSites,
selectedSite?.id,
selectedSite?.phpVersion,
selectedSite?.name,
themesList,
pluginsList,
wpVersion,
sitePort,
themeDetails?.name,
themeDetails?.isBlockTheme,
availableEditors,
] );

return <ChatContext.Provider value={ contextValue }>{ children }</ChatContext.Provider>;
};

export const useChatContext = (): ChatContextType => {
const context = useContext( ChatContext );
if ( ! context ) {
throw new Error( 'useChatContext must be used within a ChatProvider' );
}
return context;
};
5 changes: 2 additions & 3 deletions src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -577,14 +577,13 @@ export async function executeWPCLiInline(

// The parsing of arguments can include shell operators like `>` or `||` that the app don't support.
const isValidCommand = wpCliArgs.every(
( arg ) => typeof arg === 'string' || arg instanceof String
( arg: unknown ) => typeof arg === 'string' || arg instanceof String
);
if ( ! isValidCommand ) {
throw Error( `Can't execute wp-cli command with arguments: ${ args }` );
}

const { stdout, stderr } = await executeWPCli( projectPath, wpCliArgs as string[] );
return { stdout, stderr };
return await executeWPCli( projectPath, wpCliArgs as string[] );
}

export async function getThumbnailData( _event: IpcMainInvokeEvent, id: string ) {
Expand Down
4 changes: 3 additions & 1 deletion src/lib/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,9 @@ const setCommand = ( command: string ) => {
const [ action, ...args ] = parse( command );

// The parsing of arguments can include shell operators like `>` or `||` that the app don't support.
const isValidCommand = args.every( ( arg ) => typeof arg === 'string' || arg instanceof String );
const isValidCommand = args.every(
( arg: unknown ) => typeof arg === 'string' || arg instanceof String
);
if ( ! isValidCommand ) {
throw Error( `Can't execute command: ${ command }` );
}
Expand Down
28 changes: 18 additions & 10 deletions src/lib/is-installed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,24 @@ import { app } from 'electron';
import fs from 'fs';
import path from 'path';

const appPaths: Record< keyof InstalledApps, string > =
process.platform == 'win32'
? {
vscode: path.join( app.getPath( 'appData' ), 'Code' ),
phpstorm: '', // Disable phpSotrm for Windows
}
: {
vscode: '/Applications/Visual Studio Code.app',
phpstorm: '/Applications/PhpStorm.app',
};
let appPaths: Record< keyof InstalledApps, string >;

if ( process.platform === 'darwin' ) {
appPaths = {
vscode: '/Applications/Visual Studio Code.app',
phpstorm: '/Applications/PhpStorm.app',
};
} else if ( process.platform === 'linux' ) {
appPaths = {
vscode: '/usr/bin/code',
phpstorm: '/usr/bin/phpstorm',
};
} else if ( process.platform === 'win32' ) {
appPaths = {
vscode: path.join( app.getPath( 'appData' ), 'Code' ),
phpstorm: '', // Disable phpStorm for Windows
};
}

export function isInstalled( key: keyof typeof appPaths ): boolean {
if ( ! appPaths[ key ] ) {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/windows-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { app, dialog, shell } from 'electron';
import { app, dialog } from 'electron';
import path from 'path';
import { __ } from '@wordpress/i18n';
import sudo from 'sudo-prompt';
Expand Down
10 changes: 5 additions & 5 deletions vendor/wp-now/src/execute-wp-cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { downloadWpCli } from './download';
import getWpCliPath from './get-wp-cli-path';
import getWpNowConfig, { WPNowMode } from './config';
import getWpNowConfig, { WPNowMode, WPNowOptions } from './config';
import { DEFAULT_PHP_VERSION, DEFAULT_WORDPRESS_VERSION } from './constants';
import { phpVar } from '@php-wasm/util';
import { NodePHP } from '@php-wasm/node';
Expand All @@ -10,9 +10,9 @@ const isWindows = process.platform === 'win32';
/**
* This is an unstable API. Multiple wp-cli commands may not work due to a current limitation on php-wasm and pthreads.
*/
export async function executeWPCli ( projectPath: string, args: string[] ): Promise<{ stdout: string; stderr: string; }> {
export async function executeWPCli( projectPath: string, args: string[] ): Promise<{ stdout: string; stderr: string; }> {
await downloadWpCli();
const options = await getWpNowConfig({
let options = await getWpNowConfig({
kozer marked this conversation as resolved.
Show resolved Hide resolved
php: DEFAULT_PHP_VERSION,
wp: DEFAULT_WORDPRESS_VERSION,
path: projectPath,
Expand All @@ -32,10 +32,10 @@ export async function executeWPCli ( projectPath: string, args: string[] ): Prom
const stderrPath = '/tmp/stderr';
const wpCliPath = '/tmp/wp-cli.phar';
const runCliPath = '/tmp/run-cli.php';
await php.writeFile(stderrPath, '');
php.writeFile(stderrPath, '');
php.mount(getWpCliPath(), wpCliPath);

await php.writeFile(
php.writeFile(
runCliPath,
`<?php
// Set up the environment to emulate a shell script
Expand Down
Loading