Cody: Fallback to keyword context for invalid embeddings client (#50952)

RE: https://github.com/sourcegraph/sourcegraph/issues/50876 

This PR addresses the issues raised by a customer and beyang ([slack
thread](https://sourcegraph.slack.com/archives/C04NPH6SZMW/p1681861031576389?thread_ts=1681859730.152309&channel=C04NPH6SZMW&message_ts=1681861031.576389))
regarding cody.codebase:
1. make it visible to the user what context mechanism is being used
1. we should fall back to keyword search if embeddings don't work
1. also lack of embeddings should surface a user-visible error

### PR Summary

#### Issue 1: make it visible to the user what context mechanism is
being used

Solution from this PR: Show the current fetching method in the lower
left corner

![Screenshot 2023-04-20 at 3 43 34
PM](https://user-images.githubusercontent.com/68532117/233503321-706393a7-ba5c-43ae-93b4-54ae09485c91.png)

![Screenshot 2023-04-20 at 3 44 56
PM](https://user-images.githubusercontent.com/68532117/233503446-e593b685-62fd-49cf-a847-0187936df0b7.png)

#### Issue 2: fall back to keyword search when embedding is not
available

Solution from this PR: Fallback to keyword search when:
- codebase is not provided
- embeddings are not available for the current codebase

When codebase is not provided and the cody.useContext is set to
'embedding' or 'blended', we will now fallback to use keyword search
(see context fetching mode in lower left corner)
![Screenshot 2023-04-20 at 3 45 32
PM](https://user-images.githubusercontent.com/68532117/233503939-0e653a48-5322-4ae0-bbcc-315daa46911b.png)

When code is provided but Cody is unable to connect to the codebase, we
will now fallback to use keyword search (see context fetching mode in
lower left corner)
![Screenshot 2023-04-20 at 3 44 56
PM](https://user-images.githubusercontent.com/68532117/233503446-e593b685-62fd-49cf-a847-0187936df0b7.png)

#### Issue 4:  lack of embeddings should surface a user-visible error

Solution from this PR: display message on codebase change / invalid
codebase in UI (see screenshots above)

When the cody.codebase is change, a pop up will also show up and ask
user to reload VS Code (see lower right corner):
![Screenshot 2023-04-20 at 3 44 15
PM](https://user-images.githubusercontent.com/68532117/233504281-6fd7e0fb-c4ff-4ad9-b82f-5441661d07a1.png)

#### other minor fix

- Disable chat submission on enter key press when chat is in progress

## Test plan

<!-- All pull requests REQUIRE a test plan:
https://docs.sourcegraph.com/dev/background-information/testing_principles
-->

See screenshot shared above. Passed all unit test

```
 PASS   cody  src/configuration.test.ts
 PASS   cody  src/completions/context.test.ts
 PASS   cody  src/keyword-context/local-keyword-context-fetcher.test.ts

Test Suites: 3 passed, 3 total
Tests:       5 passed, 5 total
Snapshots:   0 total
Time:        2.127 s
Ran all test suites.
```

---------

Co-authored-by: Philipp Spiess <hello@philippspiess.com>
This commit is contained in:
Beatrix 2023-04-21 11:25:48 -07:00 committed by GitHub
parent 541837b9af
commit df999af89a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 101 additions and 28 deletions

View File

@ -1,4 +1,8 @@
import { ConfigurationUseContext } from '../configuration'
export interface ChatContextStatus {
mode?: ConfigurationUseContext
connection?: boolean
codebase?: string
filePath?: string
}

View File

@ -26,19 +26,21 @@ export class CodebaseContext {
public async getContextMessages(query: string, options: ContextSearchOptions): Promise<ContextMessage[]> {
switch (this.config.useContext) {
case 'blended':
case 'embeddings' || 'blended':
return this.embeddings
? this.getEmbeddingsContextMessages(query, options)
: this.getKeywordContextMessages(query, options)
case 'embeddings':
return this.getEmbeddingsContextMessages(query, options)
case 'keyword':
return this.getKeywordContextMessages(query, options)
default:
return []
return this.getEmbeddingsContextMessages(query, options)
}
}
public checkEmbeddingsConnection(): boolean {
return !!this.embeddings
}
// We split the context into multiple messages instead of joining them into a single giant message.
// We can gradually eliminate them from the prompt, instead of losing them all at once with a single large messeage
// when we run out of tokens.

View File

@ -106,15 +106,15 @@ export const Chat: React.FunctionComponent<ChatProps> = ({
)
const onChatSubmit = useCallback((): void => {
// Submit chat only when input is not empty
if (formInput.trim()) {
// Submit chat only when input is not empty and not in progress
if (formInput.trim() && !messageInProgress) {
onSubmit(formInput)
setHistoryIndex(inputHistory.length + 1)
setInputHistory([...inputHistory, formInput])
setInputRows(5)
setFormInput('')
}
}, [formInput, inputHistory, onSubmit, setFormInput, setInputHistory])
}, [formInput, inputHistory, messageInProgress, onSubmit, setFormInput, setInputHistory])
const onChatKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>): void => {

View File

@ -51,8 +51,7 @@
.actions {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: calc(var(--spacing) * 0.5);
align-items: center;
padding: 0 var(--spacing);
}
@ -61,6 +60,7 @@
word-break: break-word;
line-height: 150%;
}
.content pre {
padding: calc(var(--spacing) * 0.5);
overflow-x: auto;

View File

@ -92,7 +92,9 @@ export const TranscriptItem: React.FunctionComponent<
{message.displayText ? (
<CodeBlocks displayText={message.displayText} copyButtonClassName={codeBlocksCopyButtonClassName} />
) : inProgress ? (
<BlinkingCursor />
<span>
Fetching context... <BlinkingCursor />
</span>
) : null}
</div>
</div>

View File

@ -2,6 +2,7 @@
display: inline-block;
padding: 0.4rem 0.4rem 0.3rem 0.7rem;
font-size: 0.9rem;
min-width: calc(100% - 1.1rem);
}
.container-open {
padding-bottom: 0.6rem;
@ -16,7 +17,7 @@
align-items: center;
gap: 0.2rem;
cursor: pointer;
width: 100%;
padding: 0;
appearance: none;
background: none;
@ -24,6 +25,7 @@
outline: none;
color: currentColor;
font-family: inherit;
justify-content: space-between;
}
.open-close-icon {
opacity: 0.8;

View File

@ -46,3 +46,12 @@
text-overflow: ellipsis;
overflow: hidden;
}
.fail {
display: inline-flex;
align-items: center;
gap: 0.25rem;
overflow: hidden;
color: var(--vscode-errorForeground, red);
}

View File

@ -1,6 +1,6 @@
import React, { useMemo } from 'react'
import { mdiFileDocumentOutline, mdiSourceRepository } from '@mdi/js'
import { mdiFileDocumentOutline, mdiSourceRepository, mdiFileExcel } from '@mdi/js'
import classNames from 'classnames'
import { ChatContextStatus } from '@sourcegraph/cody-shared/src/chat/context'
@ -19,9 +19,9 @@ export const ChatInputContext: React.FunctionComponent<{
[
contextStatus.codebase
? {
icon: mdiSourceRepository,
icon: contextStatus.connection ? mdiSourceRepository : mdiFileExcel,
text: basename(contextStatus.codebase.replace(/^(github|gitlab)\.com\//, '')),
tooltip: contextStatus.codebase,
tooltip: contextStatus.connection ? contextStatus.codebase : 'connection failed',
}
: null,
contextStatus.filePath
@ -32,12 +32,14 @@ export const ChatInputContext: React.FunctionComponent<{
}
: null,
].filter(isDefined),
[contextStatus.codebase, contextStatus.filePath]
[contextStatus.codebase, contextStatus.connection, contextStatus.filePath]
)
return (
<div className={classNames(styles.container, className)}>
<h3 className={styles.badge}>{items.length > 0 ? 'Context' : 'No context'}</h3>
<h3 className={styles.badge}>
{contextStatus.mode && contextStatus.connection ? 'Embeddings' : 'Keyword'}
</h3>
{items.length > 0 && (
<ul className={styles.items}>
{items.map(({ icon, text, tooltip }, index) => (
@ -56,7 +58,7 @@ const ContextItem: React.FunctionComponent<{ icon: string; text: string; tooltip
tooltip,
as: Tag,
}) => (
<Tag className={styles.item}>
<Tag className={tooltip === 'connection failed' ? styles.fail : styles.item}>
<Icon svgPath={icon} className={styles.itemIcon} />
<span className={styles.itemText} title={tooltip}>
{text}

View File

@ -37,7 +37,7 @@ export async function isValidLogin(
type Config = Pick<
ConfigurationWithAccessToken,
'codebase' | 'serverEndpoint' | 'debug' | 'customHeaders' | 'accessToken'
'codebase' | 'serverEndpoint' | 'debug' | 'customHeaders' | 'accessToken' | 'useContext'
>
export class ChatViewProvider implements vscode.WebviewViewProvider, vscode.Disposable {
@ -318,6 +318,8 @@ export class ChatViewProvider implements vscode.WebviewViewProvider, vscode.Disp
void this.webview?.postMessage({
type: 'contextStatus',
contextStatus: {
mode: this.config.useContext,
connection: this.codebaseContext.checkEmbeddingsConnection(),
codebase: this.config.codebase,
filePath: editorContext ? vscode.workspace.asRelativePath(editorContext.filePath) : undefined,
},

View File

@ -1,3 +1,5 @@
import { window } from 'vscode'
import { ChatClient } from '@sourcegraph/cody-shared/src/chat/chat'
import { CodebaseContext } from '@sourcegraph/cody-shared/src/codebase-context'
import { ConfigurationWithAccessToken } from '@sourcegraph/cody-shared/src/configuration'
@ -41,6 +43,7 @@ export async function configureExternalServices(
`Cody could not find the '${initialConfig.codebase}' repository on your Sourcegraph instance.\n` +
'Please check that the repository exists and is entered correctly in the cody.codebase setting.'
console.error(errorMessage)
void window.showErrorMessage(errorMessage)
}
const embeddingsSearch = repoId && !isError(repoId) ? new SourcegraphEmbeddingsSearchClient(client, repoId) : null

View File

@ -54,9 +54,19 @@ export async function start(context: vscode.ExtensionContext): Promise<vscode.Di
}
}),
vscode.workspace.onDidChangeConfiguration(async event => {
if (event.affectsConfiguration('cody') || event.affectsConfiguration('sourcegraph')) {
if (event.affectsConfiguration('cody')) {
onConfigurationChange(await getFullConfig())
}
if (event.affectsConfiguration('cody.codebase')) {
const action = await vscode.window.showInformationMessage(
'You must reload VS Code for Cody to pick up your new codebase.',
'Reload VS Code',
'Close'
)
if (action === 'Reload VS Code') {
void vscode.commands.executeCommand('workbench.action.reloadWindow')
}
}
})
)
@ -161,13 +171,6 @@ const register = async (
await secretStorage.delete(CODY_ACCESS_TOKEN_SECRET)
logEvent('CodyVSCodeExtension:codyDeleteAccessToken:clicked')
}),
// TOS
vscode.commands.registerCommand('cody.accept-tos', version =>
localStorage.set('cody.tos-version-accepted', version)
),
vscode.commands.registerCommand('cody.get-accepted-tos-version', () =>
localStorage.get('cody.tos-version-accepted')
),
// Commands
vscode.commands.registerCommand('cody.focus', () => vscode.commands.executeCommand('cody.chat.focus')),
vscode.commands.registerCommand('cody.settings', () => chatProvider.setWebviewView('settings')),

View File

@ -24,4 +24,28 @@ button {
flex-direction: column;
overflow: auto;
flex: 1;
}
}
.error {
flex-direction: row;
display: flex;
margin: 0.5em;
padding: 1rem;
color: var(--vscode-input-foreground);
background-color: var(--vscode-inputValidation-errorBackground);
border: 2px solid var(--vscode-inputValidation-errorBorder);
justify-content: space-between;
align-items: center;
min-height: 2rem;
position: relative;
}
.close-btn {
position: absolute;
top: 0;
right: 0;
background: none;
border: none;
color: var(--vscode-input-foreground);
cursor: pointer;
}

View File

@ -28,6 +28,7 @@ export const App: React.FunctionComponent<{ vscodeAPI: VSCodeWrapper }> = ({ vsc
const [inputHistory, setInputHistory] = useState<string[] | []>([])
const [userHistory, setUserHistory] = useState<ChatHistory | null>(null)
const [contextStatus, setContextStatus] = useState<ChatContextStatus | null>(null)
const [errorMessage, setErrorMessage] = useState<string>('')
useEffect(() => {
vscodeAPI.onMessage(message => {
@ -65,6 +66,16 @@ export const App: React.FunctionComponent<{ vscodeAPI: VSCodeWrapper }> = ({ vsc
break
case 'contextStatus':
setContextStatus(message.contextStatus)
if (message.contextStatus.mode !== 'keyword' && !message.contextStatus?.codebase) {
setErrorMessage(
'Codebase is missing. A codebase must be provided via the cody.codebase setting to enable embeddings. Failling back to local keyword search for context.'
)
}
if (message.contextStatus?.codebase && !message.contextStatus?.connection) {
setErrorMessage(
'Codebase connection failed. Please make sure the codebase in your cody.codebase setting is correct and exists in your Sourcegraph instance. Falling back to local keyword search for context.'
)
}
break
case 'view':
setView(message.messages)
@ -102,7 +113,7 @@ export const App: React.FunctionComponent<{ vscodeAPI: VSCodeWrapper }> = ({ vsc
{view === 'login' && (
<Login onLogin={onLogin} isValidLogin={isValidLogin} serverEndpoint={config?.serverEndpoint} />
)}
{view && view !== 'login' && <NavBar view={view} setView={setView} devMode={Boolean(config?.debug)} />}
{view !== 'login' && <NavBar view={view} setView={setView} devMode={Boolean(config?.debug)} />}
{view === 'debug' && config?.debug && <Debug debugLog={debugLog} />}
{view === 'history' && (
<UserHistory
@ -116,6 +127,14 @@ export const App: React.FunctionComponent<{ vscodeAPI: VSCodeWrapper }> = ({ vsc
{view === 'settings' && (
<Settings setView={setView} onLogout={onLogout} serverEndpoint={config?.serverEndpoint} />
)}
{view === 'chat' && errorMessage && (
<div className="error">
Error: {errorMessage}
<button type="button" onClick={() => setErrorMessage('')} className="close-btn">
×
</button>
</div>
)}
{view === 'chat' && (
<Chat
messageInProgress={messageInProgress}

View File

@ -38,6 +38,7 @@ body[data-vscode-theme-kind='vscode-high-contrast'] .human-transcript-item {
.transcript-action {
background: var(--button-secondary-background);
color: var(--button-secondary-foreground);
font-size: var(--vscode-editor-font-size);
}
.code-blocks-copy-button {