From e37034d88b71f1bd792b466c9b37072b605dc584 Mon Sep 17 00:00:00 2001 From: Mohammad Kermani Date: Sat, 8 Mar 2025 08:02:34 +0000 Subject: [PATCH 1/4] refactor: change from SIWE_VERIFICATION_API to PATTERN_CORE_ENDPOINT env var --- .env.example | 2 +- app/(auth)/auth.ts | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index f11bcae..708841c 100644 --- a/.env.example +++ b/.env.example @@ -19,4 +19,4 @@ POSTGRES_URL=**** NEXT_PUBLIC_PROJECT_ID= NEXTAUTH_SECRET= -SIWE_VERIFICATION_API= +PATTERN_CORE_ENDPOINT= diff --git a/app/(auth)/auth.ts b/app/(auth)/auth.ts index 62215d8..eea933f 100644 --- a/app/(auth)/auth.ts +++ b/app/(auth)/auth.ts @@ -23,10 +23,11 @@ if (!projectId) { throw new Error('NEXT_PUBLIC_PROJECT_ID is not set'); } -const siweVerificationApi = process.env.SIWE_VERIFICATION_API; -if (!siweVerificationApi) { - throw new Error('SIWE_VERIFICATION_API is not set'); +const patternCoreEndpoint = process.env.PATTERN_CORE_ENDPOINT; +if (!patternCoreEndpoint) { + throw new Error('PATTERN_CORE_ENDPOINT is not set'); } +const siweVerificationApi = `${patternCoreEndpoint}/auth/verify`; const providers = [ credentialsProvider({ From 0f9be895ceda50cb5b26d904fb56c5d455d4f54e Mon Sep 17 00:00:00 2001 From: Mohammad Kermani Date: Sat, 8 Mar 2025 12:27:13 +0000 Subject: [PATCH 2/4] feat: add default workspace and project ids to session --- app/(auth)/adapter.ts | 125 ++++++++++++++++++++++++++++++++++++++ app/(auth)/auth.config.ts | 22 ++++++- app/(auth)/service.ts | 62 +++++++++++++++++++ app/(auth)/types.ts | 16 +++++ lib/utils.ts | 8 +++ package.json | 1 + 6 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 app/(auth)/adapter.ts create mode 100644 app/(auth)/service.ts create mode 100644 app/(auth)/types.ts diff --git a/app/(auth)/adapter.ts b/app/(auth)/adapter.ts new file mode 100644 index 0000000..b731d52 --- /dev/null +++ b/app/(auth)/adapter.ts @@ -0,0 +1,125 @@ +import { Ok, Err, Result } from 'ts-results-es'; + +import { extractErrorMessageOrDefault } from '@/lib/utils'; + +import { + ApiCreateProjectResponse, + ApiCreateWorkspaceResponse, + ApiListAllProjectsResponse, + ApiListAllWorkspacesResponse, +} from './types'; + +const patternCoreEndpoint = process.env.PATTERN_CORE_ENDPOINT; +if (!patternCoreEndpoint) { + throw new Error('PATTERN_CORE_ENDPOINT is not set'); +} + +/** + * Get all workspaces + * @param accessToken + * @returns result containing all workspaces of current user + */ +export const getAllWorkspaces = async ( + accessToken: string, +): Promise> => { + try { + const allWorkspacesResponse = await fetch( + `${patternCoreEndpoint}/workspace`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }, + ); + const allWorkspaces: ApiListAllWorkspacesResponse = ( + await allWorkspacesResponse.json() + ).data; + + return Ok(allWorkspaces); + } catch (error) { + return Err(extractErrorMessageOrDefault(error)); + } +}; + +/** + * Create a workspace + * @param accessToken + * @returns result containing the created workspace + */ +export const createWorkspace = async ( + accessToken: string, +): Promise> => { + try { + const response = await fetch(`${patternCoreEndpoint}/project`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: 'Default Workspace', + }), + }); + const workspace: ApiCreateWorkspaceResponse = (await response.json()).data; + + return Ok(workspace); + } catch (error) { + return Err(extractErrorMessageOrDefault(error)); + } +}; + +/** + * Get all projects + * @param accessToken + * @returns result containing all projects of current user + */ +export const getAllProjects = async ( + accessToken: string, +): Promise> => { + try { + const allProjectsResponse = await fetch(`${patternCoreEndpoint}/project`, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }); + const allProjects: ApiListAllProjectsResponse = ( + await allProjectsResponse.json() + ).data; + + return Ok(allProjects); + } catch (error) { + return Err(extractErrorMessageOrDefault(error)); + } +}; + +/** + * Create a project in a workspace + * @param accessToken + * @param workspaceId + * @returns result containing the created project + */ +export const createProjectInWorkspace = async ( + accessToken: string, + workspaceId: string, +): Promise> => { + try { + const response = await fetch(`${patternCoreEndpoint}/project`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: 'Default Project', + workspace_id: workspaceId, + }), + }); + const project: ApiCreateProjectResponse = (await response.json()).data; + + return Ok(project); + } catch (error) { + return Err(extractErrorMessageOrDefault(error)); + } +}; diff --git a/app/(auth)/auth.config.ts b/app/(auth)/auth.config.ts index 27d998e..695c1c8 100644 --- a/app/(auth)/auth.config.ts +++ b/app/(auth)/auth.config.ts @@ -1,11 +1,15 @@ -import type { SIWESession } from '@reown/appkit-siwe'; +import { type SIWESession } from '@reown/appkit-siwe'; import type { NextAuthConfig } from 'next-auth'; +import { fetchSessionPrerequisites } from './service'; + declare module 'next-auth' { interface Session extends SIWESession { address: string; chainId: number; accessToken: string; + workspaceId: string; + projectId: string; } interface User { accessToken: string; @@ -43,7 +47,7 @@ export const authConfig = { } return token; }, - session({ session, token }) { + async session({ session, token }) { if (!token.sub) { return session; } @@ -59,6 +63,20 @@ export const authConfig = { session.address = address; session.chainId = Number.parseInt(chainId, 10); session.accessToken = wrappedJWT; + + const sessionPrerequisitesResult = + await fetchSessionPrerequisites(wrappedJWT); + + if (sessionPrerequisitesResult.isErr()) { + throw new Error( + 'Cannot fetch session prerequisites (default workspace and project)', + { cause: sessionPrerequisitesResult.error }, + ); + } + + const [workspaceId, projectId] = sessionPrerequisitesResult.value; + session.workspaceId = workspaceId; + session.projectId = projectId; } return session; diff --git a/app/(auth)/service.ts b/app/(auth)/service.ts new file mode 100644 index 0000000..58ddbf8 --- /dev/null +++ b/app/(auth)/service.ts @@ -0,0 +1,62 @@ +import { Err, Ok, Result } from 'ts-results-es'; + +import { + createProjectInWorkspace, + createWorkspace, + getAllProjects, + getAllWorkspaces, +} from './adapter'; + +const patternCoreEndpoint = process.env.PATTERN_CORE_ENDPOINT; +if (!patternCoreEndpoint) { + throw new Error('PATTERN_CORE_ENDPOINT is not set'); +} + +/** + * Checks if the default workspace and project exist, if not, creates them + * @param accessToken + * @returns result containing default workspace and project ids + */ +export const fetchSessionPrerequisites = async ( + accessToken: string, +): Promise> => { + const allWorkspacesResult = await getAllWorkspaces(accessToken); + if (allWorkspacesResult.isErr()) { + return Err(allWorkspacesResult.error); + } + + const allWorkspaces = allWorkspacesResult.value; + let workspace = allWorkspaces[0]; + + if (!workspace) { + const createWorkspaceResult = await createWorkspace(accessToken); + if (createWorkspaceResult.isErr()) { + return Err(createWorkspaceResult.error); + } + + workspace = createWorkspaceResult.value; + } + + const allProjectsResult = await getAllProjects(accessToken); + if (allProjectsResult.isErr()) { + return Err(allProjectsResult.error); + } + + const allProjects = allProjectsResult.value; + let project = allProjects.find( + (project) => project.workspace_id === workspace.id, + ); + if (!project) { + const createProjectResult = await createProjectInWorkspace( + accessToken, + workspace.id, + ); + if (createProjectResult.isErr()) { + return Err(createProjectResult.error); + } + + project = createProjectResult.value; + } + + return Ok([workspace.id, project.id]); +}; diff --git a/app/(auth)/types.ts b/app/(auth)/types.ts new file mode 100644 index 0000000..c72602e --- /dev/null +++ b/app/(auth)/types.ts @@ -0,0 +1,16 @@ +export interface Workspace { + id: string; + name: string; +} + +export interface Project { + id: string; + name: string; + workspace_id: string; +} + +export type ApiListAllWorkspacesResponse = Workspace[]; +export type ApiCreateWorkspaceResponse = Workspace; + +export type ApiListAllProjectsResponse = Project[]; +export type ApiCreateProjectResponse = Project; diff --git a/lib/utils.ts b/lib/utils.ts index 685988b..6f059af 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -229,3 +229,11 @@ export function getDocumentTimestampByIndex( return documents[index].createdAt; } + +/** + * Extract error message or return a default message + * @param error any thrown error object + * @returns error message or a default message + */ +export const extractErrorMessageOrDefault = (error: unknown) => + error instanceof Error ? error.message : 'Unknown error'; diff --git a/package.json b/package.json index 52fbcba..2fda972 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "swr": "^2.2.5", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", + "ts-results-es": "^5.0.1", "usehooks-ts": "^3.1.0", "viem": "^2.23.4", "wagmi": "^2.14.11", From ac33e3f07308bf0bcd3fc7464fe91ba756766a8a Mon Sep 17 00:00:00 2001 From: Mohammad Kermani Date: Tue, 11 Mar 2025 09:21:18 +0000 Subject: [PATCH 3/4] fix: handle non-2xx response statuses in auth adapter --- app/(auth)/adapter.ts | 46 +++++++++++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/app/(auth)/adapter.ts b/app/(auth)/adapter.ts index b731d52..9b39e01 100644 --- a/app/(auth)/adapter.ts +++ b/app/(auth)/adapter.ts @@ -32,11 +32,17 @@ export const getAllWorkspaces = async ( }, }, ); - const allWorkspaces: ApiListAllWorkspacesResponse = ( - await allWorkspacesResponse.json() - ).data; - return Ok(allWorkspaces); + if (allWorkspacesResponse.ok) { + const allWorkspaces: ApiListAllWorkspacesResponse = ( + await allWorkspacesResponse.json() + ).data; + + return Ok(allWorkspaces); + } + return Err( + `Fetching workspaces failed with error code ${allWorkspacesResponse.status}`, + ); } catch (error) { return Err(extractErrorMessageOrDefault(error)); } @@ -51,7 +57,7 @@ export const createWorkspace = async ( accessToken: string, ): Promise> => { try { - const response = await fetch(`${patternCoreEndpoint}/project`, { + const response = await fetch(`${patternCoreEndpoint}/workspace`, { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, @@ -61,9 +67,14 @@ export const createWorkspace = async ( name: 'Default Workspace', }), }); - const workspace: ApiCreateWorkspaceResponse = (await response.json()).data; - return Ok(workspace); + if (response.ok) { + const workspace: ApiCreateWorkspaceResponse = (await response.json()) + .data; + + return Ok(workspace); + } + return Err(`Creating workspace failed with error code ${response.status}`); } catch (error) { return Err(extractErrorMessageOrDefault(error)); } @@ -84,11 +95,16 @@ export const getAllProjects = async ( 'Content-Type': 'application/json', }, }); - const allProjects: ApiListAllProjectsResponse = ( - await allProjectsResponse.json() - ).data; + if (allProjectsResponse.ok) { + const allProjects: ApiListAllProjectsResponse = ( + await allProjectsResponse.json() + ).data; - return Ok(allProjects); + return Ok(allProjects); + } + return Err( + `Fetching projects failed with error code ${allProjectsResponse.status}`, + ); } catch (error) { return Err(extractErrorMessageOrDefault(error)); } @@ -116,9 +132,13 @@ export const createProjectInWorkspace = async ( workspace_id: workspaceId, }), }); - const project: ApiCreateProjectResponse = (await response.json()).data; - return Ok(project); + if (response.ok) { + const project: ApiCreateProjectResponse = (await response.json()).data; + + return Ok(project); + } + return Err(`Creating project failed with error code ${response.status}`); } catch (error) { return Err(extractErrorMessageOrDefault(error)); } From 0ba7d5b395a41da93ecf9b9462f833bb3e9d215b Mon Sep 17 00:00:00 2001 From: Mohammad Kermani Date: Tue, 11 Mar 2025 12:12:06 +0000 Subject: [PATCH 4/4] feat: implement Pattern provider for AI SDK --- app/(chat)/adapter.ts | 193 +++++++++++++++++++++++++++++++++ app/(chat)/api/chat/route.ts | 105 +++++------------- app/(chat)/service.ts | 43 ++++++++ app/(chat)/types.ts | 10 ++ lib/ai/pattern-model.ts | 203 +++++++++++++++++++++++++++++++++++ lib/ai/pattern-provider.ts | 23 ++++ lib/ai/types.ts | 18 ++++ lib/utils.ts | 8 +- package.json | 1 + pnpm-lock.yaml | 16 ++- 10 files changed, 536 insertions(+), 84 deletions(-) create mode 100644 app/(chat)/adapter.ts create mode 100644 app/(chat)/service.ts create mode 100644 app/(chat)/types.ts create mode 100644 lib/ai/pattern-model.ts create mode 100644 lib/ai/pattern-provider.ts create mode 100644 lib/ai/types.ts diff --git a/app/(chat)/adapter.ts b/app/(chat)/adapter.ts new file mode 100644 index 0000000..cc1e7f3 --- /dev/null +++ b/app/(chat)/adapter.ts @@ -0,0 +1,193 @@ +import { Ok, Err, type Result } from 'ts-results-es'; + +import { extractErrorMessageOrDefault } from '@/lib/utils'; + +import type { + ApiCreateConversationResponse, + ApiGetConversationResponse, + ApiSendMessageResponse, + ApiSendMessageStreamedResponse, +} from './types'; + +const patternCoreEndpoint = process.env.PATTERN_CORE_ENDPOINT; +if (!patternCoreEndpoint) { + throw new Error('PATTERN_CORE_ENDPOINT is not set'); +} + +/** + * Get a conversation + * @param accessToken + * @param projectId + * @param conversationId + * @returns result containing the conversation + */ +export const getConversation = async ( + accessToken: string, + projectId: string, + conversationId: string, +): Promise> => { + try { + const conversationResponse = await fetch( + `${patternCoreEndpoint}/playground/conversation/${projectId}/${conversationId}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }, + ); + + if (conversationResponse.ok) { + const conversation: ApiGetConversationResponse = ( + await conversationResponse.json() + ).data; + + return Ok(conversation); + } + if (conversationResponse.status === 404) { + return Ok(null); + } + return Err( + `Fetching conversation failed with error code ${conversationResponse.status}`, + ); + } catch (error) { + return Err(extractErrorMessageOrDefault(error)); + } +}; + +/** + * Create a conversation + * @param accessToken + * @param projectId + * @param conversationName + * @returns result containing the created conversation + */ +export const createConversation = async ( + accessToken: string, + projectId: string, + conversationName: string, +): Promise> => { + try { + const conversationResponse = await fetch( + `${patternCoreEndpoint}/playground/conversation`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: conversationName, + project_id: projectId, + }), + }, + ); + + if (conversationResponse.ok) { + const conversation: ApiCreateConversationResponse = ( + await conversationResponse.json() + ).data; + + return Ok(conversation); + } + return Err( + `Creating conversation failed with error code ${conversationResponse.status}`, + ); + } catch (error) { + return Err(extractErrorMessageOrDefault(error)); + } +}; + +/** + * Send a message + * @param accessToken + * @param projectId + * @param conversationId + * @param message + * @returns result containing model response + */ +export const sendMessage = async ( + accessToken: string, + projectId: string, + conversationId: string, + message: string, +): Promise> => { + try { + const messageResponse = await fetch( + `${patternCoreEndpoint}/playground/conversation/${projectId}/${conversationId}/chat`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + message, + message_type: 'text', + stream: true, + }), + }, + ); + + if (messageResponse.ok) { + const modelResponse: ApiSendMessageResponse = ( + await messageResponse.json() + ).data; + + return Ok(modelResponse); + } + + return Err( + `Sending message failed with error code ${messageResponse.status}`, + ); + } catch (error) { + return Err(extractErrorMessageOrDefault(error)); + } +}; + +/** + * Send a message and return a stream + * @param accessToken + * @param projectId + * @param conversationId + * @param message + * @returns result containing the readable stream of response + */ +export const sendMessageStreamed = async ( + accessToken: string, + projectId: string, + conversationId: string, + message: string, +): Promise> => { + try { + const messageResponse = await fetch( + `${patternCoreEndpoint}/playground/conversation/${projectId}/${conversationId}/chat`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + message, + message_type: 'text', + stream: true, + }), + }, + ); + + if (messageResponse.ok) { + const responseStream = messageResponse.body; + + return responseStream + ? Ok(messageResponse.body) + : Err('Message was sent but stream object is null'); + } + + return Err( + `Sending message failed with error code ${messageResponse.status}`, + ); + } catch (error) { + return Err(extractErrorMessageOrDefault(error)); + } +}; diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index e075d23..458a562 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -6,39 +6,26 @@ import { } from 'ai'; import { auth } from '@/app/(auth)/auth'; -import { myProvider } from '@/lib/ai/models'; -import { systemPrompt } from '@/lib/ai/prompts'; -import { - deleteChatById, - getChatById, - saveChat, - saveMessages, -} from '@/lib/db/queries'; -import { - generateUUID, - getMostRecentUserMessage, - sanitizeResponseMessages, -} from '@/lib/utils'; +import { patternProvider } from '@/lib/ai/pattern-provider'; +import { deleteChatById, getChatById } from '@/lib/db/queries'; +import { generateUUID, getMostRecentUserMessage } from '@/lib/utils'; -import { generateTitleFromUserMessage } from '../../actions'; -import { createDocument } from '@/lib/ai/tools/create-document'; -import { updateDocument } from '@/lib/ai/tools/update-document'; -import { requestSuggestions } from '@/lib/ai/tools/request-suggestions'; -import { getWeather } from '@/lib/ai/tools/get-weather'; +import { getOrCreateConversation } from '../../service'; export const maxDuration = 60; export async function POST(request: Request) { - const { - id, - messages, - selectedChatModel, - }: { id: string; messages: Array; selectedChatModel: string } = + const { id, messages }: { id: string; messages: Array } = await request.json(); const session = await auth(); - if (!session || !session.user || !session.user.id) { + if ( + !session || + !session.chainId || + !session.address || + !session.accessToken + ) { return new Response('Unauthorized', { status: 401 }); } @@ -48,71 +35,31 @@ export async function POST(request: Request) { return new Response('No user message found', { status: 400 }); } - const chat = await getChatById({ id }); + const conversationResult = await getOrCreateConversation( + session.accessToken, + session.projectId, + id, + ); - if (!chat) { - const title = await generateTitleFromUserMessage({ message: userMessage }); - await saveChat({ id, userId: session.user.id, title }); + if (conversationResult.isErr()) { + return new Response(conversationResult.error, { status: 400 }); } - await saveMessages({ - messages: [{ ...userMessage, createdAt: new Date(), chatId: id }], - }); + const conversation = conversationResult.value; return createDataStreamResponse({ execute: (dataStream) => { const result = streamText({ - model: myProvider.languageModel(selectedChatModel), - system: systemPrompt({ selectedChatModel }), + model: patternProvider(), messages, - maxSteps: 5, - experimental_activeTools: - selectedChatModel === 'chat-model-reasoning' - ? [] - : [ - 'getWeather', - 'createDocument', - 'updateDocument', - 'requestSuggestions', - ], experimental_transform: smoothStream({ chunking: 'word' }), experimental_generateMessageId: generateUUID, - tools: { - getWeather, - createDocument: createDocument({ session, dataStream }), - updateDocument: updateDocument({ session, dataStream }), - requestSuggestions: requestSuggestions({ - session, - dataStream, - }), - }, - onFinish: async ({ response, reasoning }) => { - if (session.user?.id) { - try { - const sanitizedResponseMessages = sanitizeResponseMessages({ - messages: response.messages, - reasoning, - }); - - await saveMessages({ - messages: sanitizedResponseMessages.map((message) => { - return { - id: message.id, - chatId: id, - role: message.role, - content: message.content, - createdAt: new Date(), - }; - }), - }); - } catch (error) { - console.error('Failed to save chat'); - } - } - }, - experimental_telemetry: { - isEnabled: true, - functionId: 'stream-text', + providerOptions: { + pattern: { + conversationId: conversation.id, + accessToken: session.accessToken, + projectId: session.projectId, + }, }, }); diff --git a/app/(chat)/service.ts b/app/(chat)/service.ts new file mode 100644 index 0000000..391fd7a --- /dev/null +++ b/app/(chat)/service.ts @@ -0,0 +1,43 @@ +import { type Result, Err, Ok } from 'ts-results-es'; + +import { createConversation, getConversation } from './adapter'; +import type { Conversation } from './types'; + +/** + * Checks if the conversation exists and returns it, otherwise creates it + * @param accessToken + * @returns result containing the existing or created conversation + */ +export const getOrCreateConversation = async ( + accessToken: string, + projectId: string, + conversationId: string, +): Promise> => { + const conversationResult = await getConversation( + accessToken, + projectId, + conversationId, + ); + if (conversationResult.isErr()) { + return Err(conversationResult.error); + } + + let conversation = conversationResult.value; + + if (!conversation) { + const createConversationResult = await createConversation( + accessToken, + projectId, + 'Default Title', + ); + if (createConversationResult.isErr()) { + return Err(createConversationResult.error); + } + + conversation = createConversationResult.value; + } + + return Ok(conversation); +}; + +export { sendMessage, sendMessageStreamed } from './adapter'; diff --git a/app/(chat)/types.ts b/app/(chat)/types.ts new file mode 100644 index 0000000..e8aed20 --- /dev/null +++ b/app/(chat)/types.ts @@ -0,0 +1,10 @@ +export interface Conversation { + id: string; + name: string; + project_id: string; +} + +export type ApiGetConversationResponse = Conversation | null; +export type ApiCreateConversationResponse = Conversation; +export type ApiSendMessageResponse = string; +export type ApiSendMessageStreamedResponse = ReadableStream; diff --git a/lib/ai/pattern-model.ts b/lib/ai/pattern-model.ts new file mode 100644 index 0000000..aaa3531 --- /dev/null +++ b/lib/ai/pattern-model.ts @@ -0,0 +1,203 @@ +import type { + LanguageModelV1, + LanguageModelV1CallOptions, + LanguageModelV1StreamPart, +} from 'ai'; + +import { sendMessage, sendMessageStreamed } from '@/app/(chat)/service'; +import type { + PatternProviderMetadata, + PatternStreamingResponseEvent, + ToolStartEvent, +} from '@/lib/ai/types'; + +import { extractErrorMessageOrDefault } from '../utils'; + +export const patternCoreEndpoint = process.env.PATTERN_CORE_ENDPOINT; +if (!patternCoreEndpoint) { + throw new Error('PATTERN_CORE_ENDPOINT is not set'); +} + +const textDecoder = new TextDecoder(); + +export class PatternModel implements LanguageModelV1 { + readonly modelId = 'pattern-model'; + readonly provider = 'pattern'; + readonly specificationVersion = 'v1'; + readonly defaultObjectGenerationMode = 'json'; + + /** + * @param options + * @returns Pattern's metadata + */ + private extractPatternProviderMetadataFromOptions( + options: LanguageModelV1CallOptions, + ) { + const { providerMetadata } = options; + if (!providerMetadata?.pattern) { + throw new Error('Pattern metadata is not provided'); + } + + const patternProviderMetadata = + providerMetadata.pattern as unknown as PatternProviderMetadata; + + return patternProviderMetadata; + } + + /** + * @param options + * @returns last user text message + */ + private getLastUserTextMessage(options: LanguageModelV1CallOptions) { + const lastMessage = options.prompt.at(-1); + if (lastMessage?.role !== 'user') { + throw new Error('Last message is not from user'); + } + const lastMessageContent = lastMessage.content[0]; + if (lastMessageContent.type !== 'text') { + throw new Error('Last message is not of type text'); + } + + return lastMessageContent.text; + } + + /** + * Generate a reasoning text from a tool start event to be shown to user, + * including tool name and its inputs + * @param event + * @returns Reasoning text for a tool start event + */ + private getToolStartReasoningText(event: ToolStartEvent) { + return `#### Tool call - ${event.tool}\n${Object.entries(event.tool_input ?? {}).map((entry) => `* **${entry[0]}**: ${entry[1]}\n`)}`; + } + + /** + * Get transform stream to be used for transforming Pattern streaming chunks + * to AI SDK supported chunks + */ + private getTransformStream() { + return new TransformStream({ + transform: (chunk, controller) => { + if (ArrayBuffer.isView(chunk)) { + try { + const chunkBuffer = new Uint8Array( + chunk.buffer, + chunk.byteOffset, + chunk.byteLength, + ); + const parsedChunk = textDecoder.decode(chunkBuffer).trim(); + const events = parsedChunk + .split('\n') + .map((eventObject) => JSON.parse(eventObject)); + + events.forEach((event: PatternStreamingResponseEvent) => { + if (event.type === 'token') { + controller.enqueue({ + type: 'text-delta', + textDelta: event.data, + }); + } else if (event.type === 'tool_start') { + const text = this.getToolStartReasoningText(event); + controller.enqueue({ + type: 'reasoning', + textDelta: text, + }); + } else { + controller.enqueue({ + type: 'error', + error: `Event type is not supported`, + }); + } + }); + } catch (error) { + controller.enqueue({ + type: 'error', + error: extractErrorMessageOrDefault( + error, + 'An unknown error occurred when transforming response chunk', + ), + }); + } + } else { + controller.enqueue({ + type: 'error', + error: 'Chunk type is not supported', + }); + } + }, + flush: (controller) => { + controller.enqueue({ + type: 'finish', + finishReason: 'stop', + usage: { + completionTokens: 0, + promptTokens: 0, + }, + }); + }, + }); + } + + async doGenerate( + options: LanguageModelV1CallOptions, + ): Promise>> { + const { accessToken, conversationId, projectId } = + this.extractPatternProviderMetadataFromOptions(options); + + const lastMessage = this.getLastUserTextMessage(options); + + const messageResult = await sendMessage( + accessToken, + projectId, + conversationId, + lastMessage, + ); + + if (messageResult.isErr()) { + throw new Error(messageResult.error); + } + + const response = messageResult.value; + return { + text: response, + usage: { + promptTokens: 0, + completionTokens: 0, + }, + finishReason: 'stop', + rawCall: { + rawPrompt: options.prompt, + rawSettings: {}, + }, + }; + } + async doStream( + options: LanguageModelV1CallOptions, + ): Promise>> { + const { accessToken, conversationId, projectId } = + this.extractPatternProviderMetadataFromOptions(options); + + const lastMessage = this.getLastUserTextMessage(options); + + const streamResult = await sendMessageStreamed( + accessToken, + projectId, + conversationId, + lastMessage, + ); + + if (streamResult.isErr()) { + throw new Error(streamResult.error); + } + + const stream = streamResult.value; + + return { + rawCall: { + rawPrompt: options.prompt, + rawSettings: {}, + }, + stream: stream.pipeThrough(this.getTransformStream()), + }; + } +} diff --git a/lib/ai/pattern-provider.ts b/lib/ai/pattern-provider.ts new file mode 100644 index 0000000..aa665f2 --- /dev/null +++ b/lib/ai/pattern-provider.ts @@ -0,0 +1,23 @@ +import { PatternModel } from './pattern-model'; + +export type PatternProvider = () => PatternModel + +export function createPatternProvider(): PatternProvider { + const createModel = () => new PatternModel(); + + const provider = function () { + if (new.target) { + throw new Error( + 'The model factory function cannot be called with the new keyword.', + ); + } + + return createModel(); + }; + + provider.chat = createModel; + + return provider; +} + +export const patternProvider = createPatternProvider(); diff --git a/lib/ai/types.ts b/lib/ai/types.ts new file mode 100644 index 0000000..00529db --- /dev/null +++ b/lib/ai/types.ts @@ -0,0 +1,18 @@ +export interface TokenEvent { + type: 'token'; + data: string; +} + +export interface ToolStartEvent { + type: 'tool_start'; + tool: string; + tool_input: Record; +} + +export type PatternStreamingResponseEvent = TokenEvent | ToolStartEvent; + +export interface PatternProviderMetadata { + accessToken: string; + conversationId: string; + projectId: string; +} diff --git a/lib/utils.ts b/lib/utils.ts index 6f059af..d0cff3a 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -2,9 +2,7 @@ import type { CoreAssistantMessage, CoreToolMessage, Message, - TextStreamPart, ToolInvocation, - ToolSet, } from 'ai'; import { type ClassValue, clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; @@ -235,5 +233,7 @@ export function getDocumentTimestampByIndex( * @param error any thrown error object * @returns error message or a default message */ -export const extractErrorMessageOrDefault = (error: unknown) => - error instanceof Error ? error.message : 'Unknown error'; +export const extractErrorMessageOrDefault = ( + error: unknown, + defaultError = 'Unknown error', +) => (error instanceof Error ? error.message : defaultError); diff --git a/package.json b/package.json index 2fda972..6c231a2 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "dependencies": { "@ai-sdk/fireworks": "^0.1.8", "@ai-sdk/openai": "1.1.9", + "@ai-sdk/provider-utils": "^2.1.9", "@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-python": "^6.1.6", "@codemirror/state": "^6.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8b3c01..75826de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,9 @@ importers: '@ai-sdk/openai': specifier: 1.1.9 version: 1.1.9(zod@3.23.8) + '@ai-sdk/provider-utils': + specifier: ^2.1.9 + version: 2.1.9(zod@3.23.8) '@codemirror/lang-javascript': specifier: ^6.2.2 version: 6.2.2 @@ -202,6 +205,9 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.14) + ts-results-es: + specifier: ^5.0.1 + version: 5.0.1 usehooks-ts: specifier: ^3.1.0 version: 3.1.0(react@19.0.0-rc-45804af1-20241021) @@ -7926,6 +7932,12 @@ packages: integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==, } + ts-results-es@5.0.1: + resolution: + { + integrity: sha512-HjX/7HxQe2bXkbp8pHTjy4Ir9eHIDnDDsLDphhGqy6I9iZ/vD4QXWEIlrVRZsEX+kS2jIiiF/mnl0nKnPTiYFw==, + } + tsconfig-paths@3.15.0: resolution: { @@ -9447,7 +9459,7 @@ snapshots: dependencies: '@ethereumjs/tx': 4.2.0 '@types/debug': 4.1.12 - debug: 4.3.7 + debug: 4.4.0 semver: 7.6.3 superstruct: 1.0.4 transitivePeerDependencies: @@ -14275,6 +14287,8 @@ snapshots: ts-interface-checker@0.1.13: {} + ts-results-es@5.0.1: {} + tsconfig-paths@3.15.0: dependencies: '@types/json5': 0.0.29