Skip to content

Commit 119d2f4

Browse files
authored
feat(amazonq): re-add basic chat through a language server (aws#6781)
## Problem - We removed chat a while ago from the `feature/amazonqLSP` branch because we were just starting with inline suggestions ## Solution - Now that we're moving to use chat we can re-add it - Implement explain, refactor, fix, optimize, sendToPrompt, openTab - Add a feature flag for enabling/disabling chat - **note**: amazonqLSP and amazonqChatLSP must be enabled in order for chat support to work - extended the baseinstaller so that individual lsps installers can provider their own resource paths ## Notes - this is the equivalent of https://github.com/aws/language-servers/blob/55253ea258b2d34bcc47b93e9998b1e9898e8f2a/client/vscode/src/chatActivation.ts but integrated with our codebase - since commands require the webview we pass in the view provider to all commands so we can lazy evaluate the webview when required - certain message listeners are only registered _after_ the UI is resolved --- - Treat all work as PUBLIC. Private `feature/x` branches will not be squash-merged at release time. - Your code changes must meet the guidelines in [CONTRIBUTING.md](https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#guidelines). - License: I confirm that my contribution is made under the terms of the Apache 2.0 license.
1 parent c7179db commit 119d2f4

File tree

12 files changed

+454
-24
lines changed

12 files changed

+454
-24
lines changed

packages/amazonq/src/extension.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ export async function activateAmazonQCommon(context: vscode.ExtensionContext, is
157157

158158
context.subscriptions.push(
159159
Experiments.instance.onDidChange(async (event) => {
160-
if (event.key === 'amazonqLSP') {
160+
if (event.key === 'amazonqLSP' || event.key === 'amazonqChatLSP') {
161161
await vscode.window
162162
.showInformationMessage(
163163
'Amazon Q LSP setting has changed. Reload VS Code for the changes to take effect.',

packages/amazonq/src/extensionNode.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,15 @@ import * as vscode from 'vscode'
77
import { activateAmazonQCommon, amazonQContextPrefix, deactivateCommon } from './extension'
88
import { DefaultAmazonQAppInitContext } from 'aws-core-vscode/amazonq'
99
import { activate as activateQGumby } from 'aws-core-vscode/amazonqGumby'
10-
import { ExtContext, globals, CrashMonitoring, getLogger, isNetworkError, isSageMaker } from 'aws-core-vscode/shared'
10+
import {
11+
ExtContext,
12+
globals,
13+
CrashMonitoring,
14+
getLogger,
15+
isNetworkError,
16+
isSageMaker,
17+
Experiments,
18+
} from 'aws-core-vscode/shared'
1119
import { filetypes, SchemaService } from 'aws-core-vscode/sharedNode'
1220
import { updateDevMode } from 'aws-core-vscode/dev'
1321
import { CommonAuthViewProvider } from 'aws-core-vscode/login'
@@ -43,8 +51,10 @@ async function activateAmazonQNode(context: vscode.ExtensionContext) {
4351
extensionContext: context,
4452
}
4553

46-
await activateCWChat(context)
47-
await activateQGumby(extContext as ExtContext)
54+
if (!Experiments.instance.get('amazonqChatLSP', false)) {
55+
await activateCWChat(context)
56+
await activateQGumby(extContext as ExtContext)
57+
}
4858

4959
const authProvider = new CommonAuthViewProvider(
5060
context,
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { window } from 'vscode'
7+
import { LanguageClient } from 'vscode-languageclient'
8+
import { AmazonQChatViewProvider } from './webviewProvider'
9+
import { registerCommands } from './commands'
10+
import { registerLanguageServerEventListener, registerMessageListeners } from './messages'
11+
import { globals } from 'aws-core-vscode/shared'
12+
13+
export function activate(languageClient: LanguageClient, encryptionKey: Buffer, mynahUIPath: string) {
14+
const provider = new AmazonQChatViewProvider(mynahUIPath)
15+
16+
globals.context.subscriptions.push(
17+
window.registerWebviewViewProvider(AmazonQChatViewProvider.viewType, provider, {
18+
webviewOptions: {
19+
retainContextWhenHidden: true,
20+
},
21+
})
22+
)
23+
24+
/**
25+
* Commands are registered independent of the webview being open because when they're executed
26+
* they focus the webview
27+
**/
28+
registerCommands(provider)
29+
registerLanguageServerEventListener(languageClient, provider)
30+
31+
provider.onDidResolveWebview(() => {
32+
registerMessageListeners(languageClient, provider, encryptionKey)
33+
})
34+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import * as vscode from 'vscode'
7+
import { Commands, globals } from 'aws-core-vscode/shared'
8+
import { window } from 'vscode'
9+
import { AmazonQChatViewProvider } from './webviewProvider'
10+
11+
export function registerCommands(provider: AmazonQChatViewProvider) {
12+
globals.context.subscriptions.push(
13+
registerGenericCommand('aws.amazonq.explainCode', 'Explain', provider),
14+
registerGenericCommand('aws.amazonq.refactorCode', 'Refactor', provider),
15+
registerGenericCommand('aws.amazonq.fixCode', 'Fix', provider),
16+
registerGenericCommand('aws.amazonq.optimizeCode', 'Optimize', provider),
17+
Commands.register('aws.amazonq.sendToPrompt', (data) => {
18+
const triggerType = getCommandTriggerType(data)
19+
const selection = getSelectedText()
20+
21+
void focusAmazonQPanel().then(() => {
22+
void provider.webview?.postMessage({
23+
command: 'sendToPrompt',
24+
params: { selection: selection, triggerType },
25+
})
26+
})
27+
}),
28+
Commands.register('aws.amazonq.openTab', () => {
29+
void focusAmazonQPanel().then(() => {
30+
void provider.webview?.postMessage({
31+
command: 'aws/chat/openTab',
32+
params: {},
33+
})
34+
})
35+
})
36+
)
37+
}
38+
39+
function getSelectedText(): string {
40+
const editor = window.activeTextEditor
41+
if (editor) {
42+
const selection = editor.selection
43+
const selectedText = editor.document.getText(selection)
44+
return selectedText
45+
}
46+
47+
return ' '
48+
}
49+
50+
function getCommandTriggerType(data: any): string {
51+
// data is undefined when commands triggered from keybinding or command palette. Currently no
52+
// way to differentiate keybinding and command palette, so both interactions are recorded as keybinding
53+
return data === undefined ? 'hotkeys' : 'contextMenu'
54+
}
55+
56+
function registerGenericCommand(commandName: string, genericCommand: string, provider: AmazonQChatViewProvider) {
57+
return Commands.register(commandName, (data) => {
58+
const triggerType = getCommandTriggerType(data)
59+
const selection = getSelectedText()
60+
61+
void focusAmazonQPanel().then(() => {
62+
void provider.webview?.postMessage({
63+
command: 'genericCommand',
64+
params: { genericCommand, selection, triggerType },
65+
})
66+
})
67+
})
68+
}
69+
70+
/**
71+
* Importing focusAmazonQPanel from aws-core-vscode/amazonq leads to several dependencies down the chain not resolving since AmazonQ chat
72+
* is currently only activated on node, but the language server is activated on both web and node.
73+
*
74+
* Instead, we just create our own as a temporary solution
75+
*/
76+
async function focusAmazonQPanel() {
77+
await vscode.commands.executeCommand('aws.amazonq.AmazonQChatView.focus')
78+
await vscode.commands.executeCommand('aws.amazonq.AmazonCommonAuth.focus')
79+
}
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import {
7+
isValidAuthFollowUpType,
8+
INSERT_TO_CURSOR_POSITION,
9+
AUTH_FOLLOW_UP_CLICKED,
10+
CHAT_OPTIONS,
11+
COPY_TO_CLIPBOARD,
12+
} from '@aws/chat-client-ui-types'
13+
import {
14+
ChatResult,
15+
chatRequestType,
16+
ChatParams,
17+
followUpClickNotificationType,
18+
quickActionRequestType,
19+
QuickActionResult,
20+
QuickActionParams,
21+
insertToCursorPositionNotificationType,
22+
} from '@aws/language-server-runtimes/protocol'
23+
import { v4 as uuidv4 } from 'uuid'
24+
import { window } from 'vscode'
25+
import { Disposable, LanguageClient, Position, State, TextDocumentIdentifier } from 'vscode-languageclient'
26+
import * as jose from 'jose'
27+
import { AmazonQChatViewProvider } from './webviewProvider'
28+
29+
export function registerLanguageServerEventListener(languageClient: LanguageClient, provider: AmazonQChatViewProvider) {
30+
languageClient.onDidChangeState(({ oldState, newState }) => {
31+
if (oldState === State.Starting && newState === State.Running) {
32+
languageClient.info(
33+
'Language client received initializeResult from server:',
34+
JSON.stringify(languageClient.initializeResult)
35+
)
36+
37+
const chatOptions = languageClient.initializeResult?.awsServerCapabilities?.chatOptions
38+
39+
void provider.webview?.postMessage({
40+
command: CHAT_OPTIONS,
41+
params: chatOptions,
42+
})
43+
}
44+
})
45+
46+
languageClient.onTelemetry((e) => {
47+
languageClient.info(`[VSCode Client] Received telemetry event from server ${JSON.stringify(e)}`)
48+
})
49+
}
50+
51+
export function registerMessageListeners(
52+
languageClient: LanguageClient,
53+
provider: AmazonQChatViewProvider,
54+
encryptionKey: Buffer
55+
) {
56+
provider.webview?.onDidReceiveMessage(async (message) => {
57+
languageClient.info(`[VSCode Client] Received ${JSON.stringify(message)} from chat`)
58+
59+
switch (message.command) {
60+
case COPY_TO_CLIPBOARD:
61+
// TODO see what we need to hook this up
62+
languageClient.info('[VSCode Client] Copy to clipboard event received')
63+
break
64+
case INSERT_TO_CURSOR_POSITION: {
65+
const editor = window.activeTextEditor
66+
let textDocument: TextDocumentIdentifier | undefined = undefined
67+
let cursorPosition: Position | undefined = undefined
68+
if (editor) {
69+
cursorPosition = editor.selection.active
70+
textDocument = { uri: editor.document.uri.toString() }
71+
}
72+
73+
languageClient.sendNotification(insertToCursorPositionNotificationType.method, {
74+
...message.params,
75+
cursorPosition,
76+
textDocument,
77+
})
78+
break
79+
}
80+
case AUTH_FOLLOW_UP_CLICKED:
81+
// TODO hook this into auth
82+
languageClient.info('[VSCode Client] AuthFollowUp clicked')
83+
break
84+
case chatRequestType.method: {
85+
const partialResultToken = uuidv4()
86+
const chatDisposable = languageClient.onProgress(chatRequestType, partialResultToken, (partialResult) =>
87+
handlePartialResult<ChatResult>(partialResult, encryptionKey, provider, message.params.tabId)
88+
)
89+
90+
const editor =
91+
window.activeTextEditor ||
92+
window.visibleTextEditors.find((editor) => editor.document.languageId !== 'Log')
93+
if (editor) {
94+
message.params.cursorPosition = [editor.selection.active]
95+
message.params.textDocument = { uri: editor.document.uri.toString() }
96+
}
97+
98+
const chatRequest = await encryptRequest<ChatParams>(message.params, encryptionKey)
99+
const chatResult = (await languageClient.sendRequest(chatRequestType.method, {
100+
...chatRequest,
101+
partialResultToken,
102+
})) as string | ChatResult
103+
void handleCompleteResult<ChatResult>(
104+
chatResult,
105+
encryptionKey,
106+
provider,
107+
message.params.tabId,
108+
chatDisposable
109+
)
110+
break
111+
}
112+
case quickActionRequestType.method: {
113+
const quickActionPartialResultToken = uuidv4()
114+
const quickActionDisposable = languageClient.onProgress(
115+
quickActionRequestType,
116+
quickActionPartialResultToken,
117+
(partialResult) =>
118+
handlePartialResult<QuickActionResult>(
119+
partialResult,
120+
encryptionKey,
121+
provider,
122+
message.params.tabId
123+
)
124+
)
125+
126+
const quickActionRequest = await encryptRequest<QuickActionParams>(message.params, encryptionKey)
127+
const quickActionResult = (await languageClient.sendRequest(quickActionRequestType.method, {
128+
...quickActionRequest,
129+
partialResultToken: quickActionPartialResultToken,
130+
})) as string | ChatResult
131+
void handleCompleteResult<ChatResult>(
132+
quickActionResult,
133+
encryptionKey,
134+
provider,
135+
message.params.tabId,
136+
quickActionDisposable
137+
)
138+
break
139+
}
140+
case followUpClickNotificationType.method:
141+
if (!isValidAuthFollowUpType(message.params.followUp.type)) {
142+
languageClient.sendNotification(followUpClickNotificationType.method, message.params)
143+
}
144+
break
145+
default:
146+
if (isServerEvent(message.command)) {
147+
languageClient.sendNotification(message.command, message.params)
148+
}
149+
break
150+
}
151+
}, undefined)
152+
}
153+
154+
function isServerEvent(command: string) {
155+
return command.startsWith('aws/chat/') || command === 'telemetry/event'
156+
}
157+
158+
async function encryptRequest<T>(params: T, encryptionKey: Buffer): Promise<{ message: string } | T> {
159+
const payload = new TextEncoder().encode(JSON.stringify(params))
160+
161+
const encryptedMessage = await new jose.CompactEncrypt(payload)
162+
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
163+
.encrypt(encryptionKey)
164+
165+
return { message: encryptedMessage }
166+
}
167+
168+
async function decodeRequest<T>(request: string, key: Buffer): Promise<T> {
169+
const result = await jose.jwtDecrypt(request, key, {
170+
clockTolerance: 60, // Allow up to 60 seconds to account for clock differences
171+
contentEncryptionAlgorithms: ['A256GCM'],
172+
keyManagementAlgorithms: ['dir'],
173+
})
174+
175+
if (!result.payload) {
176+
throw new Error('JWT payload not found')
177+
}
178+
return result.payload as T
179+
}
180+
181+
/**
182+
* Decodes partial chat responses from the language server before sending them to mynah UI
183+
*/
184+
async function handlePartialResult<T extends ChatResult>(
185+
partialResult: string | T,
186+
encryptionKey: Buffer | undefined,
187+
provider: AmazonQChatViewProvider,
188+
tabId: string
189+
) {
190+
const decryptedMessage =
191+
typeof partialResult === 'string' && encryptionKey
192+
? await decodeRequest<T>(partialResult, encryptionKey)
193+
: (partialResult as T)
194+
195+
if (decryptedMessage.body) {
196+
void provider.webview?.postMessage({
197+
command: chatRequestType.method,
198+
params: decryptedMessage,
199+
isPartialResult: true,
200+
tabId: tabId,
201+
})
202+
}
203+
}
204+
205+
/**
206+
* Decodes the final chat responses from the language server before sending it to mynah UI.
207+
* Once this is called the answer response is finished
208+
*/
209+
async function handleCompleteResult<T>(
210+
result: string | T,
211+
encryptionKey: Buffer | undefined,
212+
provider: AmazonQChatViewProvider,
213+
tabId: string,
214+
disposable: Disposable
215+
) {
216+
const decryptedMessage =
217+
typeof result === 'string' && encryptionKey ? await decodeRequest(result, encryptionKey) : result
218+
219+
void provider.webview?.postMessage({
220+
command: chatRequestType.method,
221+
params: decryptedMessage,
222+
tabId: tabId,
223+
})
224+
disposable.dispose()
225+
}

0 commit comments

Comments
 (0)