diff --git a/packages/backend/src/apps/index.ts b/packages/backend/src/apps/index.ts index 74c9a104c0..285f2a2604 100644 --- a/packages/backend/src/apps/index.ts +++ b/packages/backend/src/apps/index.ts @@ -9,6 +9,7 @@ import formsgApp from './formsg' import gathersgApp from './gathersg' import lettersgApp from './lettersg' import m365ExcelApp from './m365-excel' +import pairApp from './pair' import paysgApp from './paysg' import postmanApp from './postman' import postmanSmsApp from './postman-sms' @@ -42,6 +43,7 @@ const apps: Record = { [webhookApp.key]: webhookApp, [aisayApp.key]: aisayApp, [gathersgApp.key]: gathersgApp, + [pairApp.key]: pairApp, } export default apps diff --git a/packages/backend/src/apps/pair/actions/call-pair/index.ts b/packages/backend/src/apps/pair/actions/call-pair/index.ts new file mode 100644 index 0000000000..71e9156264 --- /dev/null +++ b/packages/backend/src/apps/pair/actions/call-pair/index.ts @@ -0,0 +1,84 @@ +import { IRawAction } from '@plumber/types' + +import { fromZodError } from 'zod-validation-error' + +import generateText from '@/apps/pair/common/generateText' +import StepError, { GenericSolution } from '@/errors/step' + +import { schema } from './schema' + +const action: IRawAction = { + name: 'Pair', + key: 'callPair', + description: + 'Enter a custom prompt to summarise, categorise or analyse data with Pair', + arguments: [ + // TODO (kevinkim-ogp): each option should link to a different + // default value for the prompt. update when the default value is provided + { + label: 'What would you like to do?', + key: 'promptType', + type: 'dropdown' as const, + required: true, + options: [ + { label: 'Analyse', value: 'analyse' }, + { label: 'Categorise', value: 'categorise' }, + { label: 'Summarise', value: 'summarise' }, + { label: 'Write', value: 'write' }, + { + label: 'Custom prompt', + value: 'custom', + description: 'Do it yourself', + }, + ], + showOptionValue: false, + }, + { + label: 'Prompt', + key: 'prompt', + type: 'rich-text' as const, + required: true, + variables: true, + customRteMenuOptions: [ + 'Bold', + 'Italic', + 'Underline', + 'divider', // specify when to show a divider + 'Heading 1', + 'Heading 2', + 'Heading 3', + 'Heading 4', + 'Bullet List', + 'Ordered List', + 'divider', + 'Undo', + 'Redo', + ], + returnMarkdown: true, + }, + ], + + async run($) { + const validatedParameters = schema.safeParse($.step.parameters) + + if (!validatedParameters.success) { + const firstError = fromZodError(validatedParameters.error).details[0] + throw new StepError( + firstError.message, + GenericSolution.ReconfigureInvalidField, + $.step.position, + $.app.name, + ) + } + + const { prompt } = validatedParameters.data + + const response = await generateText(prompt, $) + + $.setActionItem({ + raw: { data: response }, + }) + }, +} + +export default action diff --git a/packages/backend/src/apps/pair/actions/call-pair/schema.ts b/packages/backend/src/apps/pair/actions/call-pair/schema.ts new file mode 100644 index 0000000000..8971ad9726 --- /dev/null +++ b/packages/backend/src/apps/pair/actions/call-pair/schema.ts @@ -0,0 +1,9 @@ +import z from 'zod/v3' + +export const schema = z.object({ + promptType: z.enum(['analyse', 'categorise', 'summarise', 'write', 'custom']), + prompt: z.string().min(1), + responseFormat: z + .enum(['singleField', 'multipleFields']) + .default('singleField'), +}) diff --git a/packages/backend/src/apps/pair/actions/index.ts b/packages/backend/src/apps/pair/actions/index.ts new file mode 100644 index 0000000000..324dc6458d --- /dev/null +++ b/packages/backend/src/apps/pair/actions/index.ts @@ -0,0 +1,3 @@ +import callPair from './call-pair' + +export default [callPair] diff --git a/packages/backend/src/apps/pair/assets/favicon.svg b/packages/backend/src/apps/pair/assets/favicon.svg new file mode 100644 index 0000000000..7bf2196061 --- /dev/null +++ b/packages/backend/src/apps/pair/assets/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/backend/src/apps/pair/common/generateText.ts b/packages/backend/src/apps/pair/common/generateText.ts new file mode 100644 index 0000000000..798bc79f72 --- /dev/null +++ b/packages/backend/src/apps/pair/common/generateText.ts @@ -0,0 +1,63 @@ +import { IGlobalVariable } from '@plumber/types' + +import { startActiveObservation } from '@langfuse/tracing' +import { generateText as AiSdkGenerateText } from 'ai' + +import appConfig from '@/config/app' +import logger from '@/helpers/logger' +import { model, MODEL_TYPE } from '@/helpers/pair' + +async function generateText(prompt: string, $: IGlobalVariable) { + try { + const result = await startActiveObservation( + 'pair-action-generate-text', + async (trace) => { + trace.updateTrace({ + userId: $.user.email, + environment: appConfig.appEnv, + tags: ['pair', 'action', 'generate-text'], + }) + + trace.update({ input: prompt }) + + const generation = trace.startObservation( + 'pair-action-generate-text', + { + model: MODEL_TYPE, + input: [{ role: 'user', content: prompt as string }], + }, + { asType: 'generation' }, + ) + + const { text } = await AiSdkGenerateText({ + model, + prompt: [{ role: 'user', content: prompt as string }], + experimental_telemetry: { + isEnabled: true, + functionId: 'pair-action-generate-text', + metadata: { + userId: $.user.email, + }, + }, + }) + + trace.update({ output: text }) + + generation.update({ output: text }).end() + + return text + }, + ) + + return result + } catch (error) { + logger.error('Failed to generate text', { + error: error, + user: $.user.email, + }) + throw error + } + return '' +} + +export default generateText diff --git a/packages/backend/src/apps/pair/index.ts b/packages/backend/src/apps/pair/index.ts new file mode 100644 index 0000000000..49d078b3c3 --- /dev/null +++ b/packages/backend/src/apps/pair/index.ts @@ -0,0 +1,18 @@ +import { IApp } from '@plumber/types' + +import actions from './actions' + +const app: IApp = { + name: 'Pair', + key: 'pair', + description: 'Summarise, categorise or analyse data with Pair', + iconUrl: '{BASE_URL}/apps/pair/assets/favicon.svg', + authDocUrl: '', + baseUrl: '', + apiBaseUrl: '', + primaryColor: '', + actions, + category: 'ai', +} + +export default app diff --git a/packages/backend/src/graphql/queries/get-apps.ts b/packages/backend/src/graphql/queries/get-apps.ts index 37b6ec8659..2ab95672c6 100644 --- a/packages/backend/src/graphql/queries/get-apps.ts +++ b/packages/backend/src/graphql/queries/get-apps.ts @@ -9,6 +9,7 @@ import formsgApp from '@/apps/formsg/' import gathersgApp from '@/apps/gathersg' import lettersgApp from '@/apps/lettersg' import m365ExcelApp from '@/apps/m365-excel' +import pairApp from '@/apps/pair' import paysgApp from '@/apps/paysg' import postmanApp from '@/apps/postman' import postmanSmsApp from '@/apps/postman-sms' @@ -52,6 +53,7 @@ export const ACTION_APPS_RANKING = [ postmanSmsApp.key, telegramBotApp.key, slackApp.key, + pairApp.key, aisayApp.key, gathersgApp.key, customApiApp.key,