diff --git a/packages/playwright-core/src/utils/isomorphic/llm.ts b/packages/playwright-core/src/utils/isomorphic/llm.ts deleted file mode 100644 index 0a5647f4cb..0000000000 --- a/packages/playwright-core/src/utils/isomorphic/llm.ts +++ /dev/null @@ -1,195 +0,0 @@ -/** - * 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; -} - -// https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream -async function *parseSSE(body: NonNullable): AsyncGenerator<{ type: string, data: string, id: string }> { - const reader = body.pipeThrough(new TextDecoderStream()).getReader(); - let buffer = ''; - - let lastEventId = ''; - let type: string = ''; - let data = ''; - - while (true) { - const { value, done } = await reader.read(); - if (done) - break; - buffer += value; - const lines = buffer.split('\n'); - buffer = lines.pop()!; // last line is either empty or incomplete - - for (const line of lines) { - if (line.length === 0) { - if (data === '') { - data = ''; - type = ''; - continue; - } - - if (data[data.length - 1] === '\n') - data = data.substring(0, data.length - 1); - - const event = { type: type || 'message', data, id: lastEventId }; - type = ''; - data = ''; - - yield event; - } - if (line[0] === ':') - continue; - - let name = ''; - let value = ''; - const colon = line.indexOf(':'); - if (colon === -1) { - name = line; - } else { - name = line.substring(0, colon); - value = line[colon + 1] === ' ' ? line.substring(colon + 2) : line.substring(colon + 1); - } - - switch (name) { - case 'event': - type = value; - break; - case 'data': - data += value + '\n'; - break; - case 'id': - lastEventId = value; - break; - case 'retry': - default: - // not implemented - break; - } - } - } -} - -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 sseEvent of parseSSE(response.body)) { - const event = JSON.parse(sseEvent.data); - 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 { - 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 sseEvent of parseSSE(response.body)) { - const event = JSON.parse(sseEvent.data); - if (event.type === 'content_block_delta') - yield event.delta.text; - } - } -} - -export class LLMChat { - conversations = new Map(); - - 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(); - - 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; - } -} diff --git a/packages/trace-viewer/src/ui/llm.tsx b/packages/trace-viewer/src/ui/llm.tsx index 9bfced6008..10a095bd28 100644 --- a/packages/trace-viewer/src/ui/llm.tsx +++ b/packages/trace-viewer/src/ui/llm.tsx @@ -1,5 +1,200 @@ +/** + * 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 * as React from 'react'; -import { LLMChat, LLM, OpenAI, Anthropic } from '@isomorphic/llm'; +import { EventEmitter } from '@testIsomorphic/events'; + +export type LLMMessage = { + role: 'user' | 'assistant' | 'developer'; + content: string; + displayContent?: string; +}; + +interface LLM { + chatCompletion(messages: LLMMessage[], signal: AbortSignal): AsyncGenerator; +} + +// https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream +async function *parseSSE(body: NonNullable): AsyncGenerator<{ type: string, data: string, id: string }> { + const reader = body.pipeThrough(new TextDecoderStream()).getReader(); + let buffer = ''; + + let lastEventId = ''; + let type: string = ''; + let data = ''; + + while (true) { + const { value, done } = await reader.read(); + if (done) + break; + buffer += value; + const lines = buffer.split('\n'); + buffer = lines.pop()!; // last line is either empty or incomplete + + for (const line of lines) { + if (line.length === 0) { + if (data === '') { + data = ''; + type = ''; + continue; + } + + if (data[data.length - 1] === '\n') + data = data.substring(0, data.length - 1); + + const event = { type: type || 'message', data, id: lastEventId }; + type = ''; + data = ''; + + yield event; + } + if (line[0] === ':') + continue; + + let name = ''; + let value = ''; + const colon = line.indexOf(':'); + if (colon === -1) { + name = line; + } else { + name = line.substring(0, colon); + value = line[colon + 1] === ' ' ? line.substring(colon + 2) : line.substring(colon + 1); + } + + switch (name) { + case 'event': + type = value; + break; + case 'data': + data += value + '\n'; + break; + case 'id': + lastEventId = value; + break; + case 'retry': + default: + // not implemented + break; + } + } + } +} + +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 sseEvent of parseSSE(response.body)) { + const event = JSON.parse(sseEvent.data); + if (event.object === 'chat.completion.chunk') { + if (event.choices[0].finish_reason) + break; + yield event.choices[0].delta.content; + } + } + } +} + +class Anthropic implements LLM { + constructor(private apiKey: string, private baseURL = 'https://api.anthropic.com') {} + async *chatCompletion(messages: LLMMessage[]): AsyncGenerator { + 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 sseEvent of parseSSE(response.body)) { + const event = JSON.parse(sseEvent.data); + if (event.type === 'content_block_delta') + yield event.delta.text; + } + } +} + +class LLMChat { + conversations = new Map(); + + 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)!; + } +} + +class Conversation { + history: LLMMessage[]; + onChange = new EventEmitter(); + + 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 < 2; + } +} + const llmContext = React.createContext(undefined);