feat(ui): llm conversation about error

This commit is contained in:
Simon Knott 2025-02-12 14:29:08 +01:00
parent bd74fc4964
commit 9dc816f197
No known key found for this signature in database
GPG key ID: 8CEDC00028084AEC
12 changed files with 1795 additions and 193 deletions

1425
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -102,6 +102,7 @@
"node-stream-zip": "^1.15.0",
"react": "^18.1.0",
"react-dom": "^18.1.0",
"react-markdown": "^9.0.3",
"ssim.js": "^3.5.0",
"typescript": "^5.7.3",
"vite": "^5.4.14",

View file

@ -131,6 +131,10 @@ export async function installRootRedirect(server: HttpServer, traceUrls: string[
params.append('project', project);
for (const reporter of options.reporter || [])
params.append('reporter', reporter);
if (process.env.OPENAI_API_KEY)
params.append('openai_api_key', process.env.OPENAI_API_KEY);
if (process.env.ANTHROPIC_API_KEY)
params.append('anthropic_api_key', process.env.ANTHROPIC_API_KEY);
let baseUrl = '.';
if (process.env.PW_HMR) {

View file

@ -0,0 +1,150 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EventEmitter } from '@testIsomorphic/events';
export type LLMMessage = {
role: 'user' | 'assistant' | 'developer';
content: string;
displayContent?: string;
};
export interface LLM {
chatCompletion(messages: LLMMessage[], signal: AbortSignal): AsyncGenerator<string>;
}
async function *parseSSE(body: Response['body']): AsyncGenerator<string> {
const reader = body!.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done)
break;
buffer += value;
const events = buffer.split('\n\n');
buffer = events.pop()!;
for (const event of events) {
const contentStart = event.indexOf('data: ');
if (contentStart === -1)
continue;
yield event.substring(contentStart + 'data: '.length);
}
}
}
export class OpenAI implements LLM {
constructor(private apiKey: string, private baseURL = 'https://api.openai.com') {}
async *chatCompletion(messages: LLMMessage[]) {
const url = new URL('./v1/chat/completions', this.baseURL);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
'x-pw-serviceworker': 'forward',
},
body: JSON.stringify({
model: 'gpt-4o',
messages: messages.map(({ role, content }) => ({ role, content })),
stream: true,
}),
});
if (response.status !== 200 || !response.body)
throw new Error('Failed to chat with OpenAI, unexpected status: ' + response.status + await response.text());
for await (const eventString of parseSSE(response.body)) {
if (eventString === '[DONE]')
break;
const event = JSON.parse(eventString);
if (event.object === 'chat.completion.chunk') {
if (event.choices[0].finish_reason)
break;
yield event.choices[0].delta.content;
}
}
}
}
export class Anthropic implements LLM {
constructor(private apiKey: string, private baseURL = 'https://api.anthropic.com') {}
async *chatCompletion(messages: LLMMessage[]): AsyncGenerator<string> {
const response = await fetch(new URL('./v1/messages', this.baseURL), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': this.apiKey,
'anthropic-version': '2023-06-01',
'x-pw-serviceworker': 'forward',
},
body: JSON.stringify({
model: 'claude-3-5-sonnet-20241022',
messages: messages.filter(({ role }) => role !== 'developer').map(({ role, content }) => ({ role, content })),
system: messages.find(({ role }) => role === 'developer')?.content,
max_tokens: 1024,
stream: true,
})
});
if (response.status !== 200 || !response.body)
throw new Error('Failed to chat with Anthropic, unexpected status: ' + response.status + await response.text());
for await (const eventString of parseSSE(response.body)) {
const event = JSON.parse(eventString);
if (event.type === 'content_block_delta')
yield event.delta.text;
}
}
}
export class LLMChat {
conversations = new Map<string, Conversation>();
constructor(readonly api: LLM) {}
getConversation(id: string, systemPrompt: string) {
if (!this.conversations.has(id)) {
const conversation = new Conversation(this, systemPrompt);
this.conversations.set(id, conversation);
}
return this.conversations.get(id)!;
}
}
export class Conversation {
history: LLMMessage[];
onChange = new EventEmitter<void>();
constructor(private chat: LLMChat, systemPrompt: string) {
this.history = [{ role: 'developer', content: systemPrompt }];
}
async send(content: string, displayContent: string | undefined, signal: AbortSignal) {
const response: LLMMessage = { role: 'assistant', content: '' };
this.history.push({ role: 'user', content, displayContent }, response);
this.onChange.fire();
for await (const chunk of this.chat.api.chatCompletion(this.history, signal)) {
response.content += chunk;
this.onChange.fire();
}
}
isEmpty() {
return this.history.length === 1;
}
}

View file

@ -78,6 +78,9 @@ async function doFetch(event: FetchEvent): Promise<Response> {
if (event.request.url.startsWith('chrome-extension://'))
return fetch(event.request);
if (event.request.headers.get('x-pw-serviceworker') === 'forward')
return fetch(event.request);
const request = event.request;
const client = await self.clients.get(event.clientId);

View file

@ -0,0 +1,96 @@
/* Main container */
.chat-container {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
color: #e0e0e0;
}
.messages-container {
flex: 1;
overflow-y: auto;
padding: 16px;
gap: 16px;
}
.message {
gap: 12px;
max-width: 85%;
}
.user-message {
flex-direction: row-reverse;
margin-left: auto;
width: fit-content
}
.message-icon {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--vscode-titleBar-inactiveBackground);
flex-shrink: 0;
}
.message-content {
background-color: var(--vscode-titleBar-inactiveBackground);
color: var(--vscode-titleBar-activeForeground);
padding: 1px 8px;
margin-bottom: 16px;
}
.message-content pre {
text-wrap: auto;
}
.user-message .message-content {
background-color: var(--vscode-titleBar-activeBackground);
}
/* Input form styles */
.input-form {
display: flex;
height: 50px;
gap: 8px;
padding: 10px;
background-color: var(--vscode-sideBar-background);
border-top: 1px solid var(--vscode-sideBarSectionHeader-border);
}
.message-input {
flex: 1;
padding: 8px 12px;
border: 1px solid var(--vscode-settings-textInputBorder);
background-color: var(--vscode-settings-textInputBackground);
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.message-input:focus {
border-color: #0078d4;
}
.send-button {
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
background-color: var(--vscode-button-background);
border: none;
color: white;
cursor: pointer;
transition: background-color 0.2s;
}
.send-button:hover {
background-color: var(--vscode-button-hoverBackground);
}
.send-button:disabled {
background-color: var(--vscode-disabledForeground);
cursor: not-allowed;
}

View file

@ -0,0 +1,81 @@
import { useCallback, useEffect, useState } from 'react';
import Markdown from 'react-markdown'
import './aiConversation.css';
import { clsx } from '@web/uiUtils';
import { Conversation, LLMMessage } from '@isomorphic/llm';
export function AIConversation({ history, conversation, firstPrompt }: { history: LLMMessage[], conversation: Conversation, firstPrompt?: LLMMessage }) {
const [input, setInput] = useState('');
const [abort, setAbort] = useState<AbortController>();
const send = useCallback(async (prompt: string, visiblePrompt?: string) => {
const controller = new AbortController();
setAbort(controller);
try {
await conversation.send(prompt, visiblePrompt, controller.signal);
} finally {
setAbort(undefined);
}
}, [conversation]);
const onSubmit = useCallback(async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setInput('');
const content = new FormData(event.target as any).get('content') as string;
await send(content);
}, [send]);
useEffect(() => {
if (!conversation.isEmpty())
return;
if (!firstPrompt)
return;
send(firstPrompt.content, firstPrompt.displayContent);
}, [conversation, firstPrompt, send]);
return (
<div className="chat-container">
<div className="messages-container">
{history.filter(({ role }) => role !== 'developer').map((message, index) => (
<div
key={'' + index}
className={clsx('message', message.role === 'user' && 'user-message')}
>
{message.role === 'assistant' && (
<div className="message-icon">
<img src="playwright-logo.svg" />
</div>
)}
<div className="message-content">
<Markdown>{message.displayContent ?? message.content}</Markdown>
</div>
</div>
))}
</div>
<form onSubmit={onSubmit} className="input-form">
<input
type="text"
name='content'
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask a question..."
className="message-input"
/>
{abort ? (
<button type="button" className="send-button" onClick={(evt) => {
evt.preventDefault()
abort.abort()
}}>
Cancel
</button>
) : (
<button type="submit" className="send-button" disabled={!input.trim()}>
Send
</button>
)}
</form>
</div>
);
}

View file

@ -25,6 +25,11 @@ import { CopyToClipboardTextButton } from './copyToClipboard';
import { attachmentURL } from './attachmentsTab';
import { fixTestPrompt } from '@web/components/prompts';
import type { GitCommitInfo } from '@testIsomorphic/types';
import { AIConversation } from './aiConversation';
import { ToolbarButton } from '@web/components/toolbarButton';
import { useLLMChat, useLLMConversation } from './llm';
import { useAsyncMemo } from '@web/uiUtils';
import { LLMMessage } from '@isomorphic/llm';
const GitCommitInfoContext = React.createContext<GitCommitInfo | undefined>(undefined);
@ -36,33 +41,31 @@ export function useGitCommitInfo() {
return React.useContext(GitCommitInfoContext);
}
const PromptButton: React.FC<{
error: string;
actions: modelUtil.ActionTraceEventInContext[];
}> = ({ error, actions }) => {
const [pageSnapshot, setPageSnapshot] = React.useState<string>();
React.useEffect(() => {
function usePageSnapshot(actions: modelUtil.ActionTraceEventInContext[]) {
return useAsyncMemo<string | undefined>(async () => {
for (const action of actions) {
for (const attachment of action.attachments ?? []) {
if (attachment.name === 'pageSnapshot') {
fetch(attachmentURL({ ...attachment, traceUrl: action.context.traceUrl })).then(async response => {
setPageSnapshot(await response.text());
});
return;
const response = await fetch(attachmentURL({ ...attachment, traceUrl: action.context.traceUrl }));
return await response.text();
}
}
}
}, [actions]);
}, [actions], undefined);
}
const gitCommitInfo = useGitCommitInfo();
const CopyPromptButton: React.FC<{
error: string;
pageSnapshot?: string;
diff?: string;
}> = ({ error, pageSnapshot, diff }) => {
const prompt = React.useMemo(
() => fixTestPrompt(
error,
gitCommitInfo?.['pull.diff'] ?? gitCommitInfo?.['revision.diff'],
diff,
pageSnapshot
),
[error, gitCommitInfo, pageSnapshot]
[error, diff, pageSnapshot]
);
return (
@ -98,12 +101,19 @@ export function useErrorsTabModel(model: modelUtil.MultiTraceModel | undefined):
export const ErrorsTab: React.FunctionComponent<{
errorsModel: ErrorsTabModel,
actions: modelUtil.ActionTraceEventInContext[],
wallTime: number,
sdkLanguage: Language,
revealInSource: (error: ErrorDescription) => void,
}> = ({ errorsModel, sdkLanguage, revealInSource, actions }) => {
}> = ({ errorsModel, sdkLanguage, revealInSource, actions, wallTime }) => {
const [showLLM, setShowLLM] = React.useState(false);
const llmAvailable = !!useLLMChat();
const pageSnapshot = usePageSnapshot(actions);
const gitCommitInfo = useGitCommitInfo();
const diff = gitCommitInfo?.['pull.diff'] ?? gitCommitInfo?.['revision.diff'];
if (!errorsModel.errors.size)
return <PlaceholderPanel text='No errors' />;
return <div className='fill' style={{ overflow: 'auto' }}>
{[...errorsModel.errors.entries()].map(([message, error]) => {
let location: string | undefined;
@ -114,24 +124,66 @@ export const ErrorsTab: React.FunctionComponent<{
location = file + ':' + stackFrame.line;
longLocation = stackFrame.file + ':' + stackFrame.line;
}
return <div key={message}>
const errorId = `error-${wallTime}-${longLocation}`;
return <div key={message} style={{ minHeight: errorsModel.errors.size === 1 ? '100%' : undefined, display: 'flex', flexDirection: 'column' }}>
<div className='hbox' style={{
alignItems: 'center',
padding: '5px 10px',
minHeight: 36,
fontWeight: 'bold',
color: 'var(--vscode-errorForeground)',
flex: 0,
}}>
{error.action && renderAction(error.action, { sdkLanguage })}
{location && <div className='action-location'>
@ <span title={longLocation} onClick={() => revealInSource(error)}>{location}</span>
</div>}
<span style={{ position: 'absolute', right: '5px' }}>
<PromptButton error={message} actions={actions} />
{llmAvailable
? <ToolbarButton onClick={() => setShowLLM(v => !v)} title="Fix with AI" className='copy-to-clipboard-text-button'>Fix with AI</ToolbarButton>
: <CopyPromptButton error={message} pageSnapshot={pageSnapshot} diff={diff} />}
</span>
</div>
<ErrorMessage error={message} />
{showLLM && <AIErrorConversation error={message} pageSnapshot={pageSnapshot} conversationId={errorId} diff={diff} />}
</div>;
})}
</div>;
};
export function AIErrorConversation({ conversationId, error, pageSnapshot, diff }: { conversationId: string, error: string, pageSnapshot?: string, diff?: string }) {
const [history, conversation] = useLLMConversation(
conversationId,
[
`My Playwright test failed. What's going wrong?`,
`Please give me a suggestion how to fix it, and then explain what went wrong. Be very concise and apply Playwright best practices.`,
`Don't include many headings in your output. Make sure what you're saying is correct, and take into account whether there might be a bug in the app.`
].join('\n')
);
const firstPrompt = React.useMemo<LLMMessage>(() => {
const message: LLMMessage = {
role: 'user',
content: `Here's the error: ${error}`,
displayContent: `Help me with the error above.`
}
if (diff)
message.content += `\n\nCode diff:\n${diff}`;
if (pageSnapshot)
message.content += `\n\nPage snapshot:\n${pageSnapshot}`;
if (diff)
message.displayContent += ` Take the code diff${pageSnapshot ? ' and page snapshot' : ''} into account.`;
else if (pageSnapshot)
message.displayContent += ` Take the page snapshot into account.`;
return message;
}, [diff, pageSnapshot, error]);
return <AIConversation history={history} conversation={conversation} firstPrompt={firstPrompt} />;
}

View file

@ -0,0 +1,39 @@
import * as React from 'react';
import { LLMChat, LLM, OpenAI, Anthropic } from '@isomorphic/llm';
const llmContext = React.createContext<LLMChat | undefined>(undefined);
export function LLMProvider({ openai, anthropic, children }: React.PropsWithChildren<{ openai?: string, anthropic?: string }>) {
const chat = React.useMemo(() => {
let llm: LLM | undefined;
if (openai)
llm = new OpenAI(openai);
if (anthropic)
llm = new Anthropic(anthropic);
if (llm)
return new LLMChat(llm);
}, [openai, anthropic]);
return <llmContext.Provider value={chat}>{children}</llmContext.Provider>;
};
export function useLLMChat() {
return React.useContext(llmContext);
};
export function useLLMConversation(id: string, systemPrompt: string) {
const chat = useLLMChat();
if (!chat)
throw new Error('No LLM chat available, make sure theres a LLMProvider above');
const conversation = React.useMemo(() => chat.getConversation(id, systemPrompt), [chat, id]);
const [history, setHistory] = React.useState(conversation.history);
React.useEffect(() => {
function update() {
setHistory([...conversation.history]);
}
update();
const subscription = conversation.onChange.event(update);
return subscription.dispose;
}, [conversation]);
return [history, conversation] as const;
};

View file

@ -38,6 +38,7 @@ import { TraceView } from './uiModeTraceView';
import { SettingsView } from './settingsView';
import { DefaultSettingsView } from './defaultSettingsView';
import { GitCommitInfoProvider } from './errorsTab';
import { LLMProvider } from './llm';
let xtermSize = { cols: 80, rows: 24 };
const xtermDataSource: XtermDataSource = {
@ -61,6 +62,8 @@ const queryParams = {
updateSnapshots: (searchParams.get('updateSnapshots') as 'all' | 'none' | 'missing' | undefined) || undefined,
reporters: searchParams.has('reporter') ? searchParams.getAll('reporter') : undefined,
pathSeparator: searchParams.get('pathSeparator') || '/',
openai_api_key: searchParams.get('openai_api_key') || undefined,
anthropic_api_key: searchParams.get('anthropic_api_key') || undefined,
};
if (queryParams.updateSnapshots && !['all', 'none', 'missing'].includes(queryParams.updateSnapshots))
queryParams.updateSnapshots = undefined;
@ -398,7 +401,7 @@ export const UIModeView: React.FC<{}> = ({
});
}, [closeInstallDialog, testServerConnection]);
return <div className='vbox ui-mode'>
return <LLMProvider openai={queryParams.openai_api_key} anthropic={queryParams.anthropic_api_key}><div className='vbox ui-mode'>
{!hasBrowsers && <dialog ref={dialogRef}>
<div className='title'><span className='codicon codicon-lightbulb'></span>Install browsers</div>
<div className='body'>
@ -527,5 +530,5 @@ export const UIModeView: React.FC<{}> = ({
</div>
}
/>
</div>;
</div></LLMProvider>;
};

View file

@ -199,7 +199,7 @@ export const Workbench: React.FunctionComponent<{
else
setRevealedError(error);
selectPropertiesTab('source');
}} actions={model?.actions ?? []} />
}} actions={model?.actions ?? []} wallTime={model?.wallTime ?? 0} />
};
// Fallback location w/o action stands for file / test.

View file

@ -0,0 +1,92 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { test, expect, retries } from './ui-mode-fixtures';
test.describe.configure({ mode: 'parallel', retries });
test.beforeAll(() => process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS = '1');
test.afterAll(() => delete process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS);
test('openai', async ({ runUITest }) => {
const { page } = await runUITest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('trace test', async ({ page }) => {
await page.setContent('<button>Submit</button>');
expect(1).toBe(2);
});
`,
}, {
OPENAI_API_KEY: 'fake-key'
});
await page.context().route('https://api.openai.com/**', async (route, request) => {
expect(await request.headerValue('authorization')).toBe('Bearer fake-key');
expect(request.postData()).toContain(`- button \\"Submit\\"`);
const event = {
object: 'chat.completion.chunk',
choices: [{ delta: { content: 'This is a mock response' } }]
};
await route.fulfill({
body: `\n\ndata: ${JSON.stringify(event)}\n\n`
});
});
await page.getByTitle('Run all').click();
await page.getByText('Errors', { exact: true }).click();
await page.getByRole('button', { name: 'Fix with AI' }).click();
await expect(page.getByRole('tabpanel', { name: 'Errors' })).toMatchAriaSnapshot(`
- tabpanel "Errors":
- paragraph: Help me with the error above. Take the page snapshot into account.
- paragraph: This is a mock response
`);
});
test('anthropic', async ({ runUITest }) => {
const { page } = await runUITest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('trace test', async ({ page }) => {
await page.setContent('<button>Submit</button>');
expect(1).toBe(2);
});
`,
}, {
ANTHROPIC_API_KEY: 'fake-key'
});
await page.context().route('https://api.anthropic.com/**', async (route, request) => {
expect(await request.headerValue('x-api-key')).toBe('fake-key');
expect(request.postData()).toContain(`- button \\"Submit\\"`);
const event = {
type: 'content_block_delta',
delta: { text: 'This is a mock response' },
};
await route.fulfill({
body: `\n\ndata: ${JSON.stringify(event)}\n\n`
});
});
await page.getByTitle('Run all').click();
await page.getByText('Errors', { exact: true }).click();
await page.getByRole('button', { name: 'Fix with AI' }).click();
await expect(page.getByRole('tabpanel', { name: 'Errors' })).toMatchAriaSnapshot(`
- tabpanel "Errors":
- paragraph: Help me with the error above. Take the page snapshot into account.
- paragraph: This is a mock response
`);
});