From 9d070bb05fd4f41fb0c223c5947e07813a2e4efa Mon Sep 17 00:00:00 2001 From: kevinkim-ogp Date: Mon, 10 Nov 2025 14:17:02 +0800 Subject: [PATCH 1/3] feat: pair action single field response --- packages/backend/src/apps/index.ts | 2 + .../src/apps/pair/actions/call-pair/index.ts | 142 ++++++++++++++++++ .../src/apps/pair/actions/call-pair/schema.ts | 9 ++ .../backend/src/apps/pair/actions/index.ts | 3 + .../backend/src/apps/pair/assets/favicon.svg | 4 + .../src/apps/pair/common/generateText.ts | 63 ++++++++ packages/backend/src/apps/pair/index.ts | 18 +++ .../backend/src/graphql/queries/get-apps.ts | 2 + 8 files changed, 243 insertions(+) create mode 100644 packages/backend/src/apps/pair/actions/call-pair/index.ts create mode 100644 packages/backend/src/apps/pair/actions/call-pair/schema.ts create mode 100644 packages/backend/src/apps/pair/actions/index.ts create mode 100644 packages/backend/src/apps/pair/assets/favicon.svg create mode 100644 packages/backend/src/apps/pair/common/generateText.ts create mode 100644 packages/backend/src/apps/pair/index.ts 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..4c746a9ecd --- /dev/null +++ b/packages/backend/src/apps/pair/actions/call-pair/index.ts @@ -0,0 +1,142 @@ +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, + }, + { + label: 'How do you want the response?', + key: 'responseFormat', + type: 'dropdown' as const, + required: true, + showOptionValue: false, + options: [ + { label: 'A single text response', value: 'singleField' }, + { + label: 'Separate fields (split into multiple outputs)', + value: 'multipleFields', + }, + ], + }, + { + label: 'Response fields', + key: 'responseFields', + type: 'multirow-multicol' as const, + required: true, + hiddenIf: { + fieldKey: 'responseFormat', + fieldValue: 'multipleFields', + op: 'not_equals', + }, + subFields: [ + { + placeholder: 'Field name', + key: 'fieldName', + type: 'string' as const, + required: true, + variables: false, + }, + { + placeholder: 'Field type', + key: 'fieldType', + type: 'dropdown' as const, + required: true, + showOptionValue: false, + options: [ + { label: 'Text', value: 'text' }, + { label: 'Number', value: 'number' }, + { label: 'Category', value: 'category' }, + ], + }, + { + placeholder: 'Categories (comma-separated)', + key: 'fieldCategories', + type: 'string' as const, + variables: true, + hiddenIf: { + fieldKey: 'fieldType', + fieldValue: 'category', + op: 'not_equals', + }, + customStyle: { flex: 3 }, + }, + ], + }, + ], + + 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..5770027391 --- /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'], + }) + + 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, From dcd1f388a500848854fbf63a4a8383a08deb0e24 Mon Sep 17 00:00:00 2001 From: kevinkim-ogp Date: Mon, 10 Nov 2025 14:23:24 +0800 Subject: [PATCH 2/3] chore: remove responseFormat, responseFields first --- .../src/apps/pair/actions/call-pair/index.ts | 58 ------------------- 1 file changed, 58 deletions(-) diff --git a/packages/backend/src/apps/pair/actions/call-pair/index.ts b/packages/backend/src/apps/pair/actions/call-pair/index.ts index 4c746a9ecd..71e9156264 100644 --- a/packages/backend/src/apps/pair/actions/call-pair/index.ts +++ b/packages/backend/src/apps/pair/actions/call-pair/index.ts @@ -56,64 +56,6 @@ const action: IRawAction = { ], returnMarkdown: true, }, - { - label: 'How do you want the response?', - key: 'responseFormat', - type: 'dropdown' as const, - required: true, - showOptionValue: false, - options: [ - { label: 'A single text response', value: 'singleField' }, - { - label: 'Separate fields (split into multiple outputs)', - value: 'multipleFields', - }, - ], - }, - { - label: 'Response fields', - key: 'responseFields', - type: 'multirow-multicol' as const, - required: true, - hiddenIf: { - fieldKey: 'responseFormat', - fieldValue: 'multipleFields', - op: 'not_equals', - }, - subFields: [ - { - placeholder: 'Field name', - key: 'fieldName', - type: 'string' as const, - required: true, - variables: false, - }, - { - placeholder: 'Field type', - key: 'fieldType', - type: 'dropdown' as const, - required: true, - showOptionValue: false, - options: [ - { label: 'Text', value: 'text' }, - { label: 'Number', value: 'number' }, - { label: 'Category', value: 'category' }, - ], - }, - { - placeholder: 'Categories (comma-separated)', - key: 'fieldCategories', - type: 'string' as const, - variables: true, - hiddenIf: { - fieldKey: 'fieldType', - fieldValue: 'category', - op: 'not_equals', - }, - customStyle: { flex: 3 }, - }, - ], - }, ], async run($) { From 2a587fb80ed1234efbbfcf2da200e9e82ddbce0d Mon Sep 17 00:00:00 2001 From: kevinkim-ogp Date: Mon, 10 Nov 2025 15:04:01 +0800 Subject: [PATCH 3/3] chore: update trace tags --- packages/backend/src/apps/pair/common/generateText.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/apps/pair/common/generateText.ts b/packages/backend/src/apps/pair/common/generateText.ts index 5770027391..798bc79f72 100644 --- a/packages/backend/src/apps/pair/common/generateText.ts +++ b/packages/backend/src/apps/pair/common/generateText.ts @@ -15,7 +15,7 @@ async function generateText(prompt: string, $: IGlobalVariable) { trace.updateTrace({ userId: $.user.email, environment: appConfig.appEnv, - tags: ['pair', 'action'], + tags: ['pair', 'action', 'generate-text'], }) trace.update({ input: prompt })