Merge branch 'pdf-screen-template' of github.com:Budibase/budibase into pdf-specific-components
This commit is contained in:
commit
e53efa1f48
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
|
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
|
||||||
"version": "3.8.0",
|
"version": "3.8.1",
|
||||||
"npmClient": "yarn",
|
"npmClient": "yarn",
|
||||||
"concurrency": 20,
|
"concurrency": 20,
|
||||||
"command": {
|
"command": {
|
||||||
|
|
|
@ -96,6 +96,24 @@ async function get<T extends Document>(db: Database, id: string): Promise<T> {
|
||||||
return cacheItem.doc
|
return cacheItem.doc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function tryGet<T extends Document>(
|
||||||
|
db: Database,
|
||||||
|
id: string
|
||||||
|
): Promise<T | null> {
|
||||||
|
const cache = await getCache()
|
||||||
|
const cacheKey = makeCacheKey(db, id)
|
||||||
|
let cacheItem: CacheItem<T> | null = await cache.get(cacheKey)
|
||||||
|
if (!cacheItem) {
|
||||||
|
const doc = await db.tryGet<T>(id)
|
||||||
|
if (!doc) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
cacheItem = makeCacheItem(doc)
|
||||||
|
await cache.store(cacheKey, cacheItem)
|
||||||
|
}
|
||||||
|
return cacheItem.doc
|
||||||
|
}
|
||||||
|
|
||||||
async function remove(db: Database, docOrId: any, rev?: any): Promise<void> {
|
async function remove(db: Database, docOrId: any, rev?: any): Promise<void> {
|
||||||
const cache = await getCache()
|
const cache = await getCache()
|
||||||
if (!docOrId) {
|
if (!docOrId) {
|
||||||
|
@ -123,10 +141,17 @@ export class Writethrough {
|
||||||
return put(this.db, doc, writeRateMs)
|
return put(this.db, doc, writeRateMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated use `tryGet` instead
|
||||||
|
*/
|
||||||
async get<T extends Document>(id: string) {
|
async get<T extends Document>(id: string) {
|
||||||
return get<T>(this.db, id)
|
return get<T>(this.db, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async tryGet<T extends Document>(id: string) {
|
||||||
|
return tryGet<T>(this.db, id)
|
||||||
|
}
|
||||||
|
|
||||||
async remove(docOrId: any, rev?: any) {
|
async remove(docOrId: any, rev?: any) {
|
||||||
return remove(this.db, docOrId, rev)
|
return remove(this.db, docOrId, rev)
|
||||||
}
|
}
|
||||||
|
|
|
@ -47,6 +47,9 @@ export async function getConfig<T extends Config>(
|
||||||
export async function save(
|
export async function save(
|
||||||
config: Config
|
config: Config
|
||||||
): Promise<{ id: string; rev: string }> {
|
): Promise<{ id: string; rev: string }> {
|
||||||
|
if (!config._id) {
|
||||||
|
config._id = generateConfigID(config.type)
|
||||||
|
}
|
||||||
const db = context.getGlobalDB()
|
const db = context.getGlobalDB()
|
||||||
return db.put(config)
|
return db.put(config)
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,7 +12,6 @@ describe("configs", () => {
|
||||||
|
|
||||||
const setDbPlatformUrl = async (dbUrl: string) => {
|
const setDbPlatformUrl = async (dbUrl: string) => {
|
||||||
const settingsConfig = {
|
const settingsConfig = {
|
||||||
_id: configs.generateConfigID(ConfigType.SETTINGS),
|
|
||||||
type: ConfigType.SETTINGS,
|
type: ConfigType.SETTINGS,
|
||||||
config: {
|
config: {
|
||||||
platformUrl: dbUrl,
|
platformUrl: dbUrl,
|
||||||
|
|
|
@ -60,6 +60,11 @@ export const StaticDatabases = {
|
||||||
SCIM_LOGS: {
|
SCIM_LOGS: {
|
||||||
name: "scim-logs",
|
name: "scim-logs",
|
||||||
},
|
},
|
||||||
|
// Used by self-host users making use of Budicloud resources. Introduced when
|
||||||
|
// we started letting self-host users use Budibase AI in the cloud.
|
||||||
|
SELF_HOST_CLOUD: {
|
||||||
|
name: "self-host-cloud",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const APP_PREFIX = prefixed(DocumentType.APP)
|
export const APP_PREFIX = prefixed(DocumentType.APP)
|
||||||
|
|
|
@ -157,6 +157,33 @@ export async function doInTenant<T>(
|
||||||
return newContext(updates, task)
|
return newContext(updates, task)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We allow self-host licensed users to make use of some Budicloud services
|
||||||
|
// (e.g. Budibase AI). When they do this, they use their license key as an API
|
||||||
|
// key. We use that license key to identify the tenant ID, and we set the
|
||||||
|
// context to be self-host using cloud. This affects things like where their
|
||||||
|
// quota documents get stored (because we want to avoid creating a new global
|
||||||
|
// DB for each self-host tenant).
|
||||||
|
export async function doInSelfHostTenantUsingCloud<T>(
|
||||||
|
tenantId: string,
|
||||||
|
task: () => T
|
||||||
|
): Promise<T> {
|
||||||
|
const updates = { tenantId, isSelfHostUsingCloud: true }
|
||||||
|
return newContext(updates, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSelfHostUsingCloud() {
|
||||||
|
const context = Context.get()
|
||||||
|
return !!context?.isSelfHostUsingCloud
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSelfHostCloudDB() {
|
||||||
|
const context = Context.get()
|
||||||
|
if (!context || !context.isSelfHostUsingCloud) {
|
||||||
|
throw new Error("Self-host cloud DB not found")
|
||||||
|
}
|
||||||
|
return getDB(StaticDatabases.SELF_HOST_CLOUD.name)
|
||||||
|
}
|
||||||
|
|
||||||
export async function doInAppContext<T>(
|
export async function doInAppContext<T>(
|
||||||
appId: string,
|
appId: string,
|
||||||
task: () => T
|
task: () => T
|
||||||
|
@ -325,6 +352,11 @@ export function getGlobalDB(): Database {
|
||||||
if (!context || (env.MULTI_TENANCY && !context.tenantId)) {
|
if (!context || (env.MULTI_TENANCY && !context.tenantId)) {
|
||||||
throw new Error("Global DB not found")
|
throw new Error("Global DB not found")
|
||||||
}
|
}
|
||||||
|
if (context.isSelfHostUsingCloud) {
|
||||||
|
throw new Error(
|
||||||
|
"Global DB not found - self-host users using cloud don't have a global DB"
|
||||||
|
)
|
||||||
|
}
|
||||||
return getDB(baseGlobalDBName(context?.tenantId))
|
return getDB(baseGlobalDBName(context?.tenantId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -344,6 +376,11 @@ export function getAppDB(opts?: any): Database {
|
||||||
if (!appId) {
|
if (!appId) {
|
||||||
throw new Error("Unable to retrieve app DB - no app ID.")
|
throw new Error("Unable to retrieve app DB - no app ID.")
|
||||||
}
|
}
|
||||||
|
if (isSelfHostUsingCloud()) {
|
||||||
|
throw new Error(
|
||||||
|
"App DB not found - self-host users using cloud don't have app DBs"
|
||||||
|
)
|
||||||
|
}
|
||||||
return getDB(appId, opts)
|
return getDB(appId, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { GoogleSpreadsheet } from "google-spreadsheet"
|
||||||
// keep this out of Budibase types, don't want to expose context info
|
// keep this out of Budibase types, don't want to expose context info
|
||||||
export type ContextMap = {
|
export type ContextMap = {
|
||||||
tenantId?: string
|
tenantId?: string
|
||||||
|
isSelfHostUsingCloud?: boolean
|
||||||
appId?: string
|
appId?: string
|
||||||
identity?: IdentityContext
|
identity?: IdentityContext
|
||||||
environmentVariables?: Record<string, string>
|
environmentVariables?: Record<string, string>
|
||||||
|
|
|
@ -6,7 +6,13 @@
|
||||||
Multiselect,
|
Multiselect,
|
||||||
Button,
|
Button,
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
import { CalculationType, canGroupBy, isNumeric } from "@budibase/types"
|
import {
|
||||||
|
CalculationType,
|
||||||
|
canGroupBy,
|
||||||
|
FieldType,
|
||||||
|
isNumeric,
|
||||||
|
isNumericStaticFormula,
|
||||||
|
} from "@budibase/types"
|
||||||
import InfoDisplay from "@/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte"
|
import InfoDisplay from "@/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte"
|
||||||
import { getContext } from "svelte"
|
import { getContext } from "svelte"
|
||||||
import DetailPopover from "@/components/common/DetailPopover.svelte"
|
import DetailPopover from "@/components/common/DetailPopover.svelte"
|
||||||
|
@ -94,10 +100,15 @@
|
||||||
if (fieldSchema.calculationType) {
|
if (fieldSchema.calculationType) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
// static numeric formulas will work
|
||||||
|
if (isNumericStaticFormula(fieldSchema)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
// Only allow numeric columns for most calculation types
|
// Only allow numeric columns for most calculation types
|
||||||
if (
|
if (
|
||||||
self.type !== CalculationType.COUNT &&
|
self.type !== CalculationType.COUNT &&
|
||||||
!isNumeric(fieldSchema.type)
|
!isNumeric(fieldSchema.type) &&
|
||||||
|
fieldSchema.responseType !== FieldType.NUMBER
|
||||||
) {
|
) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
|
@ -45,6 +45,16 @@
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
screenComponentSettings = [
|
screenComponentSettings = [
|
||||||
|
{
|
||||||
|
key: "width",
|
||||||
|
label: "Width",
|
||||||
|
control: Select,
|
||||||
|
props: {
|
||||||
|
options: ["Extra small", "Small", "Medium", "Large", "Max"],
|
||||||
|
placeholder: "Default",
|
||||||
|
disabled: !!screen.layoutId,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "props.layout",
|
key: "props.layout",
|
||||||
label: "Layout",
|
label: "Layout",
|
||||||
|
@ -109,16 +119,6 @@
|
||||||
label: "On screen load",
|
label: "On screen load",
|
||||||
control: ButtonActionEditor,
|
control: ButtonActionEditor,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "width",
|
|
||||||
label: "Width",
|
|
||||||
control: Select,
|
|
||||||
props: {
|
|
||||||
options: ["Extra small", "Small", "Medium", "Large", "Max"],
|
|
||||||
placeholder: "Default",
|
|
||||||
disabled: !!screen.layoutId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...screenComponentSettings,
|
...screenComponentSettings,
|
||||||
{
|
{
|
||||||
key: "urlTest",
|
key: "urlTest",
|
||||||
|
|
|
@ -26,7 +26,9 @@
|
||||||
|
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<Icon name="InfoOutline" size="S" />
|
<Icon name="InfoOutline" size="S" />
|
||||||
<Body size="S">These settings apply to all screens</Body>
|
<Body size="S">
|
||||||
|
These settings apply to all screens. PDFs are always light theme.
|
||||||
|
</Body>
|
||||||
</div>
|
</div>
|
||||||
<Layout noPadding gap="S">
|
<Layout noPadding gap="S">
|
||||||
<Layout noPadding gap="XS">
|
<Layout noPadding gap="XS">
|
||||||
|
|
|
@ -58,7 +58,7 @@
|
||||||
// Get initial set of allowed components
|
// Get initial set of allowed components
|
||||||
let allowedComponents = []
|
let allowedComponents = []
|
||||||
const definition = componentStore.getDefinition(component?._component)
|
const definition = componentStore.getDefinition(component?._component)
|
||||||
if (definition.legalDirectChildren?.length) {
|
if (definition?.legalDirectChildren?.length) {
|
||||||
allowedComponents = definition.legalDirectChildren.map(x => {
|
allowedComponents = definition.legalDirectChildren.map(x => {
|
||||||
return `@budibase/standard-components/${x}`
|
return `@budibase/standard-components/${x}`
|
||||||
})
|
})
|
||||||
|
@ -67,7 +67,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build up list of illegal children from ancestors
|
// Build up list of illegal children from ancestors
|
||||||
let illegalChildren = definition.illegalChildren || []
|
let illegalChildren = definition?.illegalChildren || []
|
||||||
path.forEach(ancestor => {
|
path.forEach(ancestor => {
|
||||||
// Sidepanels and modals can be nested anywhere in the component tree, but really they are always rendered at the top level.
|
// Sidepanels and modals can be nested anywhere in the component tree, but really they are always rendered at the top level.
|
||||||
// Because of this, it doesn't make sense to carry over any parent illegal children to them, so the array is reset here.
|
// Because of this, it doesn't make sense to carry over any parent illegal children to them, so the array is reset here.
|
||||||
|
|
|
@ -63,7 +63,7 @@
|
||||||
<img alt="A form containing data" src={pdf} width="185" />
|
<img alt="A form containing data" src={pdf} width="185" />
|
||||||
</div>
|
</div>
|
||||||
<div class="text">
|
<div class="text">
|
||||||
<Body size="M">PDF Editor</Body>
|
<Body size="M">PDF</Body>
|
||||||
<Body size="XS">Create, edit and export your PDF</Body>
|
<Body size="XS">Create, edit and export your PDF</Body>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -99,8 +99,8 @@ export class PDFScreen extends Screen {
|
||||||
selected: {},
|
selected: {},
|
||||||
},
|
},
|
||||||
_children: [],
|
_children: [],
|
||||||
_instanceName: "",
|
_instanceName: "PDF",
|
||||||
title: "PDF Editor",
|
title: "PDF",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,11 +6,7 @@ import { Roles } from "@/constants/backend"
|
||||||
const pdf = ({ route, screens }) => {
|
const pdf = ({ route, screens }) => {
|
||||||
const validRoute = getValidRoute(screens, route, Roles.BASIC)
|
const validRoute = getValidRoute(screens, route, Roles.BASIC)
|
||||||
|
|
||||||
const template = new PDFScreen()
|
const template = new PDFScreen().role(Roles.BASIC).route(validRoute).json()
|
||||||
.instanceName("PDF Editor")
|
|
||||||
.role(Roles.BASIC)
|
|
||||||
.route(validRoute)
|
|
||||||
.json()
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,12 +1,18 @@
|
||||||
<script>
|
<script>
|
||||||
import { themeStore } from "@/stores"
|
import { themeStore } from "@/stores"
|
||||||
import { setContext } from "svelte"
|
import { setContext } from "svelte"
|
||||||
import { Context } from "@budibase/bbui"
|
import { Context, Helpers } from "@budibase/bbui"
|
||||||
|
|
||||||
setContext(Context.PopoverRoot, "#theme-root")
|
export let popoverRoot = true
|
||||||
|
|
||||||
|
const id = Helpers.uuid()
|
||||||
|
|
||||||
|
if (popoverRoot) {
|
||||||
|
setContext(Context.PopoverRoot, `#id`)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div style={$themeStore.customThemeCss} id="theme-root">
|
<div style={$themeStore.customThemeCss} {id}>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
import { Heading, Button } from "@budibase/bbui"
|
import { Heading, Button } from "@budibase/bbui"
|
||||||
import { htmlToPdf, pxToPt, A4HeightPx, type PDFOptions } from "./pdf"
|
import { htmlToPdf, pxToPt, A4HeightPx, type PDFOptions } from "./pdf"
|
||||||
import { GridRowHeight } from "@/constants"
|
import { GridRowHeight } from "@/constants"
|
||||||
|
import CustomThemeWrapper from "@/components/CustomThemeWrapper.svelte"
|
||||||
|
|
||||||
const component = getContext("component")
|
const component = getContext("component")
|
||||||
const { styleable, Block, BlockComponent } = getContext("sdk")
|
const { styleable, Block, BlockComponent } = getContext("sdk")
|
||||||
|
@ -30,6 +31,7 @@
|
||||||
const generatePDF = async () => {
|
const generatePDF = async () => {
|
||||||
rendering = true
|
rendering = true
|
||||||
await tick()
|
await tick()
|
||||||
|
preprocessCSS()
|
||||||
try {
|
try {
|
||||||
const opts: PDFOptions = {
|
const opts: PDFOptions = {
|
||||||
fileName: safeName,
|
fileName: safeName,
|
||||||
|
@ -43,6 +45,19 @@
|
||||||
rendering = false
|
rendering = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const preprocessCSS = () => {
|
||||||
|
const els = document.getElementsByClassName("grid-child")
|
||||||
|
for (let el of els) {
|
||||||
|
if (!(el instanceof HTMLElement)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Get the computed values and assign them back to the style, simplifying
|
||||||
|
// the CSS that gets handled by HTML2PDF
|
||||||
|
const styles = window.getComputedStyle(el)
|
||||||
|
el.style.setProperty("grid-column-end", styles.gridColumnEnd, "important")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getDividerStyle = (idx: number) => {
|
const getDividerStyle = (idx: number) => {
|
||||||
const top = (idx + 1) * innerPageHeightPx + doubleMarginPx / 2
|
const top = (idx + 1) * innerPageHeightPx + doubleMarginPx / 2
|
||||||
return `--idx:"${idx + 1}"; --top:${top}px;`
|
return `--idx:"${idx + 1}"; --top:${top}px;`
|
||||||
|
@ -91,22 +106,23 @@
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
<div
|
<div
|
||||||
dir="ltr"
|
class="spectrum spectrum--medium spectrum--light pageContent"
|
||||||
class="spectrum spectrum--lightest spectrum--medium pageContent"
|
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
>
|
>
|
||||||
<BlockComponent
|
<CustomThemeWrapper popoverRoot={false}>
|
||||||
type="container"
|
<BlockComponent
|
||||||
props={{ layout: "grid" }}
|
type="container"
|
||||||
styles={{
|
props={{ layout: "grid" }}
|
||||||
normal: {
|
styles={{
|
||||||
height: `${gridMinHeight}px`,
|
normal: {
|
||||||
},
|
height: `${gridMinHeight}px`,
|
||||||
}}
|
},
|
||||||
context="grid"
|
}}
|
||||||
>
|
context="grid"
|
||||||
<slot />
|
>
|
||||||
</BlockComponent>
|
<slot />
|
||||||
|
</BlockComponent>
|
||||||
|
</CustomThemeWrapper>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -157,6 +173,7 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
background: white;
|
||||||
}
|
}
|
||||||
.divider {
|
.divider {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@ -171,12 +188,4 @@
|
||||||
top: calc(var(--top) + var(--margin));
|
top: calc(var(--top) + var(--margin));
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
/*.divider::after {*/
|
|
||||||
/* position: absolute;*/
|
|
||||||
/* top: -32px;*/
|
|
||||||
/* right: 24px;*/
|
|
||||||
/* content: var(--idx);*/
|
|
||||||
/* color: var(--spectrum-global-color-static-gray-400);*/
|
|
||||||
/* text-align: right;*/
|
|
||||||
/*}*/
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -116,6 +116,9 @@ export const gridLayout = (node: HTMLDivElement, metadata: GridMetadata) => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add a unique class to elements we mutate so we can easily find them later
|
||||||
|
node.classList.add("grid-child")
|
||||||
|
|
||||||
// Callback to select the component when clicking on the wrapper
|
// Callback to select the component when clicking on the wrapper
|
||||||
selectComponent = (e: Event) => {
|
selectComponent = (e: Event) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
Subproject commit 40c36f86584568d31abd6dd5b6b00dd3a458093f
|
Subproject commit 8eb981cf01151261697a8f26c08c4c28f66b8e15
|
|
@ -49,6 +49,7 @@
|
||||||
"author": "Budibase",
|
"author": "Budibase",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.27.3",
|
||||||
"@apidevtools/swagger-parser": "10.0.3",
|
"@apidevtools/swagger-parser": "10.0.3",
|
||||||
"@aws-sdk/client-dynamodb": "3.709.0",
|
"@aws-sdk/client-dynamodb": "3.709.0",
|
||||||
"@aws-sdk/client-s3": "3.709.0",
|
"@aws-sdk/client-s3": "3.709.0",
|
||||||
|
|
|
@ -47,6 +47,7 @@ async function init() {
|
||||||
VERSION: "0.0.0+local",
|
VERSION: "0.0.0+local",
|
||||||
PASSWORD_MIN_LENGTH: "1",
|
PASSWORD_MIN_LENGTH: "1",
|
||||||
OPENAI_API_KEY: "sk-abcdefghijklmnopqrstuvwxyz1234567890abcd",
|
OPENAI_API_KEY: "sk-abcdefghijklmnopqrstuvwxyz1234567890abcd",
|
||||||
|
BUDICLOUD_URL: "https://budibaseqa.app",
|
||||||
}
|
}
|
||||||
|
|
||||||
config = { ...config, ...existingConfig }
|
config = { ...config, ...existingConfig }
|
||||||
|
|
|
@ -0,0 +1,344 @@
|
||||||
|
import { mockChatGPTResponse } from "../../../tests/utilities/mocks/ai/openai"
|
||||||
|
import TestConfiguration from "../../../tests/utilities/TestConfiguration"
|
||||||
|
import nock from "nock"
|
||||||
|
import { configs, env, features, setEnv } from "@budibase/backend-core"
|
||||||
|
import {
|
||||||
|
AIInnerConfig,
|
||||||
|
ConfigType,
|
||||||
|
License,
|
||||||
|
PlanModel,
|
||||||
|
PlanType,
|
||||||
|
ProviderConfig,
|
||||||
|
} from "@budibase/types"
|
||||||
|
import { context } from "@budibase/backend-core"
|
||||||
|
import { mocks } from "@budibase/backend-core/tests"
|
||||||
|
import { MockLLMResponseFn } from "../../../tests/utilities/mocks/ai"
|
||||||
|
import { mockAnthropicResponse } from "../../../tests/utilities/mocks/ai/anthropic"
|
||||||
|
|
||||||
|
function dedent(str: string) {
|
||||||
|
return str
|
||||||
|
.split("\n")
|
||||||
|
.map(line => line.trim())
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
type SetupFn = (
|
||||||
|
config: TestConfiguration
|
||||||
|
) => Promise<() => Promise<void> | void>
|
||||||
|
interface TestSetup {
|
||||||
|
name: string
|
||||||
|
setup: SetupFn
|
||||||
|
mockLLMResponse: MockLLMResponseFn
|
||||||
|
}
|
||||||
|
|
||||||
|
function budibaseAI(): SetupFn {
|
||||||
|
return async () => {
|
||||||
|
const cleanup = setEnv({
|
||||||
|
OPENAI_API_KEY: "test-key",
|
||||||
|
})
|
||||||
|
mocks.licenses.useBudibaseAI()
|
||||||
|
return async () => {
|
||||||
|
mocks.licenses.useCloudFree()
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function customAIConfig(providerConfig: Partial<ProviderConfig>): SetupFn {
|
||||||
|
return async (config: TestConfiguration) => {
|
||||||
|
mocks.licenses.useAICustomConfigs()
|
||||||
|
|
||||||
|
const innerConfig: AIInnerConfig = {
|
||||||
|
myaiconfig: {
|
||||||
|
provider: "OpenAI",
|
||||||
|
name: "OpenAI",
|
||||||
|
apiKey: "test-key",
|
||||||
|
defaultModel: "gpt-4o-mini",
|
||||||
|
active: true,
|
||||||
|
isDefault: true,
|
||||||
|
...providerConfig,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, rev } = await config.doInTenant(
|
||||||
|
async () =>
|
||||||
|
await configs.save({
|
||||||
|
type: ConfigType.AI,
|
||||||
|
config: innerConfig,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return async () => {
|
||||||
|
mocks.licenses.useCloudFree()
|
||||||
|
|
||||||
|
await config.doInTenant(async () => {
|
||||||
|
const db = context.getGlobalDB()
|
||||||
|
await db.remove(id, rev)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allProviders: TestSetup[] = [
|
||||||
|
{
|
||||||
|
name: "OpenAI API key",
|
||||||
|
setup: async () => {
|
||||||
|
return setEnv({
|
||||||
|
OPENAI_API_KEY: "test-key",
|
||||||
|
})
|
||||||
|
},
|
||||||
|
mockLLMResponse: mockChatGPTResponse,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "OpenAI API key with custom config",
|
||||||
|
setup: customAIConfig({ provider: "OpenAI", defaultModel: "gpt-4o-mini" }),
|
||||||
|
mockLLMResponse: mockChatGPTResponse,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Anthropic API key with custom config",
|
||||||
|
setup: customAIConfig({
|
||||||
|
provider: "Anthropic",
|
||||||
|
defaultModel: "claude-3-5-sonnet-20240620",
|
||||||
|
}),
|
||||||
|
mockLLMResponse: mockAnthropicResponse,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "BudibaseAI",
|
||||||
|
setup: budibaseAI(),
|
||||||
|
mockLLMResponse: mockChatGPTResponse,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe("AI", () => {
|
||||||
|
const config = new TestConfiguration()
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await config.init()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
config.end()
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
nock.cleanAll()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe.each(allProviders)(
|
||||||
|
"provider: $name",
|
||||||
|
({ setup, mockLLMResponse }: TestSetup) => {
|
||||||
|
let cleanup: () => Promise<void> | void
|
||||||
|
beforeAll(async () => {
|
||||||
|
cleanup = await setup(config)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const maybePromise = cleanup()
|
||||||
|
if (maybePromise) {
|
||||||
|
await maybePromise
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("POST /api/ai/js", () => {
|
||||||
|
let cleanup: () => void
|
||||||
|
beforeAll(() => {
|
||||||
|
cleanup = features.testutils.setFeatureFlags("*", {
|
||||||
|
AI_JS_GENERATION: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles correct plain code response", async () => {
|
||||||
|
mockLLMResponse(`return 42`)
|
||||||
|
|
||||||
|
const { code } = await config.api.ai.generateJs({ prompt: "test" })
|
||||||
|
expect(code).toBe("return 42")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles correct markdown code response", async () => {
|
||||||
|
mockLLMResponse(
|
||||||
|
dedent(`
|
||||||
|
\`\`\`js
|
||||||
|
return 42
|
||||||
|
\`\`\`
|
||||||
|
`)
|
||||||
|
)
|
||||||
|
|
||||||
|
const { code } = await config.api.ai.generateJs({ prompt: "test" })
|
||||||
|
expect(code).toBe("return 42")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles multiple markdown code blocks returned", async () => {
|
||||||
|
mockLLMResponse(
|
||||||
|
dedent(`
|
||||||
|
This:
|
||||||
|
|
||||||
|
\`\`\`js
|
||||||
|
return 42
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Or this:
|
||||||
|
|
||||||
|
\`\`\`js
|
||||||
|
return 10
|
||||||
|
\`\`\`
|
||||||
|
`)
|
||||||
|
)
|
||||||
|
|
||||||
|
const { code } = await config.api.ai.generateJs({ prompt: "test" })
|
||||||
|
expect(code).toBe("return 42")
|
||||||
|
})
|
||||||
|
|
||||||
|
// TODO: handle when this happens
|
||||||
|
it.skip("handles no code response", async () => {
|
||||||
|
mockLLMResponse("I'm sorry, you're quite right, etc.")
|
||||||
|
const { code } = await config.api.ai.generateJs({ prompt: "test" })
|
||||||
|
expect(code).toBe("")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles LLM errors", async () => {
|
||||||
|
mockLLMResponse(() => {
|
||||||
|
throw new Error("LLM error")
|
||||||
|
})
|
||||||
|
await config.api.ai.generateJs({ prompt: "test" }, { status: 500 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("POST /api/ai/cron", () => {
|
||||||
|
it("handles correct cron response", async () => {
|
||||||
|
mockLLMResponse("0 0 * * *")
|
||||||
|
|
||||||
|
const { message } = await config.api.ai.generateCron({
|
||||||
|
prompt: "test",
|
||||||
|
})
|
||||||
|
expect(message).toBe("0 0 * * *")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles expected LLM error", async () => {
|
||||||
|
mockLLMResponse("Error generating cron: skill issue")
|
||||||
|
|
||||||
|
await config.api.ai.generateCron(
|
||||||
|
{
|
||||||
|
prompt: "test",
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles unexpected LLM error", async () => {
|
||||||
|
mockLLMResponse(() => {
|
||||||
|
throw new Error("LLM error")
|
||||||
|
})
|
||||||
|
|
||||||
|
await config.api.ai.generateCron(
|
||||||
|
{
|
||||||
|
prompt: "test",
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("BudibaseAI", () => {
|
||||||
|
const config = new TestConfiguration()
|
||||||
|
let cleanup: () => void | Promise<void>
|
||||||
|
beforeAll(async () => {
|
||||||
|
await config.init()
|
||||||
|
cleanup = await budibaseAI()(config)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if ("then" in cleanup) {
|
||||||
|
await cleanup()
|
||||||
|
} else {
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
config.end()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("POST /api/ai/chat", () => {
|
||||||
|
let envCleanup: () => void
|
||||||
|
let featureCleanup: () => void
|
||||||
|
beforeAll(() => {
|
||||||
|
envCleanup = setEnv({ SELF_HOSTED: false })
|
||||||
|
featureCleanup = features.testutils.setFeatureFlags("*", {
|
||||||
|
AI_JS_GENERATION: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
featureCleanup()
|
||||||
|
envCleanup()
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
nock.cleanAll()
|
||||||
|
const license: License = {
|
||||||
|
plan: {
|
||||||
|
type: PlanType.FREE,
|
||||||
|
model: PlanModel.PER_USER,
|
||||||
|
usesInvoicing: false,
|
||||||
|
},
|
||||||
|
features: [],
|
||||||
|
quotas: {} as any,
|
||||||
|
tenantId: config.tenantId,
|
||||||
|
}
|
||||||
|
nock(env.ACCOUNT_PORTAL_URL).get("/api/license").reply(200, license)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles correct chat response", async () => {
|
||||||
|
mockChatGPTResponse("Hi there!")
|
||||||
|
const { message } = await config.api.ai.chat({
|
||||||
|
messages: [{ role: "user", content: "Hello!" }],
|
||||||
|
licenseKey: "test-key",
|
||||||
|
})
|
||||||
|
expect(message).toBe("Hi there!")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles chat response error", async () => {
|
||||||
|
mockChatGPTResponse(() => {
|
||||||
|
throw new Error("LLM error")
|
||||||
|
})
|
||||||
|
await config.api.ai.chat(
|
||||||
|
{
|
||||||
|
messages: [{ role: "user", content: "Hello!" }],
|
||||||
|
licenseKey: "test-key",
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles no license", async () => {
|
||||||
|
nock.cleanAll()
|
||||||
|
nock(env.ACCOUNT_PORTAL_URL).get("/api/license").reply(404)
|
||||||
|
await config.api.ai.chat(
|
||||||
|
{
|
||||||
|
messages: [{ role: "user", content: "Hello!" }],
|
||||||
|
licenseKey: "test-key",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles no license key", async () => {
|
||||||
|
await config.api.ai.chat(
|
||||||
|
{
|
||||||
|
messages: [{ role: "user", content: "Hello!" }],
|
||||||
|
// @ts-expect-error - intentionally wrong
|
||||||
|
licenseKey: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -46,7 +46,7 @@ import { withEnv } from "../../../environment"
|
||||||
import { JsTimeoutError } from "@budibase/string-templates"
|
import { JsTimeoutError } from "@budibase/string-templates"
|
||||||
import { isDate } from "../../../utilities"
|
import { isDate } from "../../../utilities"
|
||||||
import nock from "nock"
|
import nock from "nock"
|
||||||
import { mockChatGPTResponse } from "../../../tests/utilities/mocks/openai"
|
import { mockChatGPTResponse } from "../../../tests/utilities/mocks/ai/openai"
|
||||||
|
|
||||||
const timestamp = new Date("2023-01-26T11:48:57.597Z").toISOString()
|
const timestamp = new Date("2023-01-26T11:48:57.597Z").toISOString()
|
||||||
tk.freeze(timestamp)
|
tk.freeze(timestamp)
|
||||||
|
|
|
@ -44,7 +44,7 @@ import { generator, structures, mocks } from "@budibase/backend-core/tests"
|
||||||
import { DEFAULT_EMPLOYEE_TABLE_SCHEMA } from "../../../db/defaultData/datasource_bb_default"
|
import { DEFAULT_EMPLOYEE_TABLE_SCHEMA } from "../../../db/defaultData/datasource_bb_default"
|
||||||
import { generateRowIdField } from "../../../integrations/utils"
|
import { generateRowIdField } from "../../../integrations/utils"
|
||||||
import { cloneDeep } from "lodash/fp"
|
import { cloneDeep } from "lodash/fp"
|
||||||
import { mockChatGPTResponse } from "../../../tests/utilities/mocks/openai"
|
import { mockChatGPTResponse } from "../../../tests/utilities/mocks/ai/openai"
|
||||||
|
|
||||||
const descriptions = datasourceDescribe({ plus: true })
|
const descriptions = datasourceDescribe({ plus: true })
|
||||||
|
|
||||||
|
|
|
@ -35,13 +35,14 @@ import {
|
||||||
ViewV2,
|
ViewV2,
|
||||||
ViewV2Schema,
|
ViewV2Schema,
|
||||||
ViewV2Type,
|
ViewV2Type,
|
||||||
|
FormulaType,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { generator, mocks } from "@budibase/backend-core/tests"
|
import { generator, mocks } from "@budibase/backend-core/tests"
|
||||||
import { datasourceDescribe } from "../../../integrations/tests/utils"
|
import { datasourceDescribe } from "../../../integrations/tests/utils"
|
||||||
import merge from "lodash/merge"
|
import merge from "lodash/merge"
|
||||||
import { quotas } from "@budibase/pro"
|
import { quotas } from "@budibase/pro"
|
||||||
import { context, db, events, roles, setEnv } from "@budibase/backend-core"
|
import { context, db, events, roles, setEnv } from "@budibase/backend-core"
|
||||||
import { mockChatGPTResponse } from "../../../tests/utilities/mocks/openai"
|
import { mockChatGPTResponse } from "../../../tests/utilities/mocks/ai/openai"
|
||||||
import nock from "nock"
|
import nock from "nock"
|
||||||
|
|
||||||
const descriptions = datasourceDescribe({ plus: true })
|
const descriptions = datasourceDescribe({ plus: true })
|
||||||
|
@ -3865,6 +3866,48 @@ if (descriptions.length) {
|
||||||
expect(rows[0].count).toEqual(2)
|
expect(rows[0].count).toEqual(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
isInternal &&
|
||||||
|
it("should be able to max a static formula field", async () => {
|
||||||
|
const table = await config.api.table.save(
|
||||||
|
saveTableRequest({
|
||||||
|
schema: {
|
||||||
|
string: {
|
||||||
|
type: FieldType.STRING,
|
||||||
|
name: "string",
|
||||||
|
},
|
||||||
|
formula: {
|
||||||
|
type: FieldType.FORMULA,
|
||||||
|
name: "formula",
|
||||||
|
formulaType: FormulaType.STATIC,
|
||||||
|
responseType: FieldType.NUMBER,
|
||||||
|
formula: "{{ string }}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
await config.api.row.save(table._id!, {
|
||||||
|
string: "1",
|
||||||
|
})
|
||||||
|
await config.api.row.save(table._id!, {
|
||||||
|
string: "2",
|
||||||
|
})
|
||||||
|
const view = await config.api.viewV2.create({
|
||||||
|
tableId: table._id!,
|
||||||
|
name: generator.guid(),
|
||||||
|
type: ViewV2Type.CALCULATION,
|
||||||
|
schema: {
|
||||||
|
maxFormula: {
|
||||||
|
visible: true,
|
||||||
|
calculationType: CalculationType.MAX,
|
||||||
|
field: "formula",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const { rows } = await config.api.row.search(view.id)
|
||||||
|
expect(rows.length).toEqual(1)
|
||||||
|
expect(rows[0].maxFormula).toEqual(2)
|
||||||
|
})
|
||||||
|
|
||||||
it("should not be able to COUNT(DISTINCT ...) against a non-existent field", async () => {
|
it("should not be able to COUNT(DISTINCT ...) against a non-existent field", async () => {
|
||||||
await config.api.viewV2.create(
|
await config.api.viewV2.create(
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,11 +1,8 @@
|
||||||
import { createAutomationBuilder } from "../utilities/AutomationTestBuilder"
|
import { createAutomationBuilder } from "../utilities/AutomationTestBuilder"
|
||||||
import { setEnv as setCoreEnv } from "@budibase/backend-core"
|
import { setEnv as setCoreEnv, withEnv } from "@budibase/backend-core"
|
||||||
import { Model, MonthlyQuotaName, QuotaUsageType } from "@budibase/types"
|
import { Model, MonthlyQuotaName, QuotaUsageType } from "@budibase/types"
|
||||||
import TestConfiguration from "../../..//tests/utilities/TestConfiguration"
|
import TestConfiguration from "../../..//tests/utilities/TestConfiguration"
|
||||||
import {
|
import { mockChatGPTResponse } from "../../../tests/utilities/mocks/ai/openai"
|
||||||
mockChatGPTError,
|
|
||||||
mockChatGPTResponse,
|
|
||||||
} from "../../../tests/utilities/mocks/openai"
|
|
||||||
import nock from "nock"
|
import nock from "nock"
|
||||||
import { mocks } from "@budibase/backend-core/tests"
|
import { mocks } from "@budibase/backend-core/tests"
|
||||||
import { quotas } from "@budibase/pro"
|
import { quotas } from "@budibase/pro"
|
||||||
|
@ -83,7 +80,9 @@ describe("test the openai action", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should present the correct error message when an error is thrown from the createChatCompletion call", async () => {
|
it("should present the correct error message when an error is thrown from the createChatCompletion call", async () => {
|
||||||
mockChatGPTError()
|
mockChatGPTResponse(() => {
|
||||||
|
throw new Error("oh no")
|
||||||
|
})
|
||||||
|
|
||||||
const result = await expectAIUsage(0, () =>
|
const result = await expectAIUsage(0, () =>
|
||||||
createAutomationBuilder(config)
|
createAutomationBuilder(config)
|
||||||
|
@ -108,11 +107,13 @@ describe("test the openai action", () => {
|
||||||
// path, because we've enabled Budibase AI. The exact value depends on a
|
// path, because we've enabled Budibase AI. The exact value depends on a
|
||||||
// calculation we use to approximate cost. This uses Budibase's OpenAI API
|
// calculation we use to approximate cost. This uses Budibase's OpenAI API
|
||||||
// key, so we charge users for it.
|
// key, so we charge users for it.
|
||||||
const result = await expectAIUsage(14, () =>
|
const result = await withEnv({ SELF_HOSTED: false }, () =>
|
||||||
createAutomationBuilder(config)
|
expectAIUsage(14, () =>
|
||||||
.onAppAction()
|
createAutomationBuilder(config)
|
||||||
.openai({ model: Model.GPT_4O_MINI, prompt: "Hello, world" })
|
.onAppAction()
|
||||||
.test({ fields: {} })
|
.openai({ model: Model.GPT_4O_MINI, prompt: "Hello, world" })
|
||||||
|
.test({ fields: {} })
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(result.steps[0].outputs.response).toEqual("This is a test")
|
expect(result.steps[0].outputs.response).toEqual("This is a test")
|
||||||
|
|
|
@ -365,7 +365,11 @@ export function createSampleDataTableScreen(): Screen {
|
||||||
_component: "@budibase/standard-components/textv2",
|
_component: "@budibase/standard-components/textv2",
|
||||||
_styles: {
|
_styles: {
|
||||||
normal: {
|
normal: {
|
||||||
|
"--grid-desktop-col-start": 1,
|
||||||
"--grid-desktop-col-end": 3,
|
"--grid-desktop-col-end": 3,
|
||||||
|
"--grid-desktop-row-start": 1,
|
||||||
|
"--grid-desktop-row-end": 3,
|
||||||
|
"--grid-mobile-col-end": 7,
|
||||||
},
|
},
|
||||||
hover: {},
|
hover: {},
|
||||||
active: {},
|
active: {},
|
||||||
|
@ -384,6 +388,7 @@ export function createSampleDataTableScreen(): Screen {
|
||||||
"--grid-desktop-row-start": 1,
|
"--grid-desktop-row-start": 1,
|
||||||
"--grid-desktop-row-end": 3,
|
"--grid-desktop-row-end": 3,
|
||||||
"--grid-desktop-h-align": "end",
|
"--grid-desktop-h-align": "end",
|
||||||
|
"--grid-mobile-col-start": 7,
|
||||||
},
|
},
|
||||||
hover: {},
|
hover: {},
|
||||||
active: {},
|
active: {},
|
||||||
|
|
|
@ -4,6 +4,7 @@ import {
|
||||||
canGroupBy,
|
canGroupBy,
|
||||||
FieldType,
|
FieldType,
|
||||||
isNumeric,
|
isNumeric,
|
||||||
|
isNumericStaticFormula,
|
||||||
PermissionLevel,
|
PermissionLevel,
|
||||||
RelationSchemaField,
|
RelationSchemaField,
|
||||||
RenameColumn,
|
RenameColumn,
|
||||||
|
@ -176,7 +177,11 @@ async function guardCalculationViewSchema(
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCount = schema.calculationType === CalculationType.COUNT
|
const isCount = schema.calculationType === CalculationType.COUNT
|
||||||
if (!isCount && !isNumeric(targetSchema.type)) {
|
if (
|
||||||
|
!isCount &&
|
||||||
|
!isNumeric(targetSchema.type) &&
|
||||||
|
!isNumericStaticFormula(targetSchema)
|
||||||
|
) {
|
||||||
throw new HTTPError(
|
throw new HTTPError(
|
||||||
`Calculation field "${name}" references field "${schema.field}" which is not a numeric field`,
|
`Calculation field "${name}" references field "${schema.field}" which is not a numeric field`,
|
||||||
400
|
400
|
||||||
|
|
|
@ -0,0 +1,47 @@
|
||||||
|
import {
|
||||||
|
ChatCompletionRequest,
|
||||||
|
ChatCompletionResponse,
|
||||||
|
GenerateCronRequest,
|
||||||
|
GenerateCronResponse,
|
||||||
|
GenerateJsRequest,
|
||||||
|
GenerateJsResponse,
|
||||||
|
} from "@budibase/types"
|
||||||
|
import { Expectations, TestAPI } from "./base"
|
||||||
|
import { constants } from "@budibase/backend-core"
|
||||||
|
|
||||||
|
export class AIAPI extends TestAPI {
|
||||||
|
generateJs = async (
|
||||||
|
req: GenerateJsRequest,
|
||||||
|
expectations?: Expectations
|
||||||
|
): Promise<GenerateJsResponse> => {
|
||||||
|
return await this._post<GenerateJsResponse>(`/api/ai/js`, {
|
||||||
|
body: req,
|
||||||
|
expectations,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
generateCron = async (
|
||||||
|
req: GenerateCronRequest,
|
||||||
|
expectations?: Expectations
|
||||||
|
): Promise<GenerateCronResponse> => {
|
||||||
|
return await this._post<GenerateCronResponse>(`/api/ai/cron`, {
|
||||||
|
body: req,
|
||||||
|
expectations,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
chat = async (
|
||||||
|
req: ChatCompletionRequest & { licenseKey: string },
|
||||||
|
expectations?: Expectations
|
||||||
|
): Promise<ChatCompletionResponse> => {
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
if (req.licenseKey) {
|
||||||
|
headers[constants.Header.LICENSE_KEY] = req.licenseKey
|
||||||
|
}
|
||||||
|
return await this._post<ChatCompletionResponse>(`/api/ai/chat`, {
|
||||||
|
body: req,
|
||||||
|
headers,
|
||||||
|
expectations,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -22,8 +22,10 @@ import { UserPublicAPI } from "./public/user"
|
||||||
import { MiscAPI } from "./misc"
|
import { MiscAPI } from "./misc"
|
||||||
import { OAuth2API } from "./oauth2"
|
import { OAuth2API } from "./oauth2"
|
||||||
import { AssetsAPI } from "./assets"
|
import { AssetsAPI } from "./assets"
|
||||||
|
import { AIAPI } from "./ai"
|
||||||
|
|
||||||
export default class API {
|
export default class API {
|
||||||
|
ai: AIAPI
|
||||||
application: ApplicationAPI
|
application: ApplicationAPI
|
||||||
attachment: AttachmentAPI
|
attachment: AttachmentAPI
|
||||||
automation: AutomationAPI
|
automation: AutomationAPI
|
||||||
|
@ -52,6 +54,7 @@ export default class API {
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(config: TestConfiguration) {
|
constructor(config: TestConfiguration) {
|
||||||
|
this.ai = new AIAPI(config)
|
||||||
this.application = new ApplicationAPI(config)
|
this.application = new ApplicationAPI(config)
|
||||||
this.attachment = new AttachmentAPI(config)
|
this.attachment = new AttachmentAPI(config)
|
||||||
this.automation = new AutomationAPI(config)
|
this.automation = new AutomationAPI(config)
|
||||||
|
|
|
@ -0,0 +1,48 @@
|
||||||
|
import AnthropicClient from "@anthropic-ai/sdk"
|
||||||
|
import nock from "nock"
|
||||||
|
import { MockLLMResponseFn, MockLLMResponseOpts } from "."
|
||||||
|
|
||||||
|
let chatID = 1
|
||||||
|
const SPACE_REGEX = /\s+/g
|
||||||
|
|
||||||
|
export const mockAnthropicResponse: MockLLMResponseFn = (
|
||||||
|
answer: string | ((prompt: string) => string),
|
||||||
|
opts?: MockLLMResponseOpts
|
||||||
|
) => {
|
||||||
|
return nock(opts?.host || "https://api.anthropic.com")
|
||||||
|
.post("/v1/messages")
|
||||||
|
.reply((uri: string, body: nock.Body) => {
|
||||||
|
const req = body as AnthropicClient.MessageCreateParamsNonStreaming
|
||||||
|
const prompt = req.messages[0].content
|
||||||
|
if (typeof prompt !== "string") {
|
||||||
|
throw new Error("Anthropic mock only supports string prompts")
|
||||||
|
}
|
||||||
|
|
||||||
|
let content
|
||||||
|
if (typeof answer === "function") {
|
||||||
|
try {
|
||||||
|
content = answer(prompt)
|
||||||
|
} catch (e) {
|
||||||
|
return [500, "Internal Server Error"]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
content = answer
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp: AnthropicClient.Messages.Message = {
|
||||||
|
id: `${chatID++}`,
|
||||||
|
type: "message",
|
||||||
|
role: "assistant",
|
||||||
|
model: req.model,
|
||||||
|
stop_reason: "end_turn",
|
||||||
|
usage: {
|
||||||
|
input_tokens: prompt.split(SPACE_REGEX).length,
|
||||||
|
output_tokens: content.split(SPACE_REGEX).length,
|
||||||
|
},
|
||||||
|
stop_sequence: null,
|
||||||
|
content: [{ type: "text", text: content }],
|
||||||
|
}
|
||||||
|
return [200, resp]
|
||||||
|
})
|
||||||
|
.persist()
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { Scope } from "nock"
|
||||||
|
|
||||||
|
export interface MockLLMResponseOpts {
|
||||||
|
host?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MockLLMResponseFn = (
|
||||||
|
answer: string | ((prompt: string) => string),
|
||||||
|
opts?: MockLLMResponseOpts
|
||||||
|
) => Scope
|
|
@ -1,12 +1,9 @@
|
||||||
import nock from "nock"
|
import nock from "nock"
|
||||||
|
import { MockLLMResponseFn, MockLLMResponseOpts } from "."
|
||||||
|
|
||||||
let chatID = 1
|
let chatID = 1
|
||||||
const SPACE_REGEX = /\s+/g
|
const SPACE_REGEX = /\s+/g
|
||||||
|
|
||||||
interface MockChatGPTResponseOpts {
|
|
||||||
host?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
role: string
|
role: string
|
||||||
content: string
|
content: string
|
||||||
|
@ -47,19 +44,24 @@ interface ChatCompletionResponse {
|
||||||
usage: Usage
|
usage: Usage
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mockChatGPTResponse(
|
export const mockChatGPTResponse: MockLLMResponseFn = (
|
||||||
answer: string | ((prompt: string) => string),
|
answer: string | ((prompt: string) => string),
|
||||||
opts?: MockChatGPTResponseOpts
|
opts?: MockLLMResponseOpts
|
||||||
) {
|
) => {
|
||||||
return nock(opts?.host || "https://api.openai.com")
|
return nock(opts?.host || "https://api.openai.com")
|
||||||
.post("/v1/chat/completions")
|
.post("/v1/chat/completions")
|
||||||
.reply(200, (uri: string, requestBody: ChatCompletionRequest) => {
|
.reply((uri: string, body: nock.Body) => {
|
||||||
const messages = requestBody.messages
|
const req = body as ChatCompletionRequest
|
||||||
|
const messages = req.messages
|
||||||
const prompt = messages[0].content
|
const prompt = messages[0].content
|
||||||
|
|
||||||
let content
|
let content
|
||||||
if (typeof answer === "function") {
|
if (typeof answer === "function") {
|
||||||
content = answer(prompt)
|
try {
|
||||||
|
content = answer(prompt)
|
||||||
|
} catch (e) {
|
||||||
|
return [500, "Internal Server Error"]
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
content = answer
|
content = answer
|
||||||
}
|
}
|
||||||
|
@ -76,7 +78,7 @@ export function mockChatGPTResponse(
|
||||||
id: `chatcmpl-${chatID}`,
|
id: `chatcmpl-${chatID}`,
|
||||||
object: "chat.completion",
|
object: "chat.completion",
|
||||||
created: Math.floor(Date.now() / 1000),
|
created: Math.floor(Date.now() / 1000),
|
||||||
model: requestBody.model,
|
model: req.model,
|
||||||
system_fingerprint: `fp_${chatID}`,
|
system_fingerprint: `fp_${chatID}`,
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
|
@ -97,14 +99,7 @@ export function mockChatGPTResponse(
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return response
|
return [200, response]
|
||||||
})
|
})
|
||||||
.persist()
|
.persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mockChatGPTError() {
|
|
||||||
return nock("https://api.openai.com")
|
|
||||||
.post("/v1/chat/completions")
|
|
||||||
.reply(500, "Internal Server Error")
|
|
||||||
.persist()
|
|
||||||
}
|
|
|
@ -1,5 +1,18 @@
|
||||||
import { EnrichedBinding } from "../../ui"
|
import { EnrichedBinding } from "../../ui"
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
role: "system" | "user"
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatCompletionRequest {
|
||||||
|
messages: Message[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatCompletionResponse {
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface GenerateJsRequest {
|
export interface GenerateJsRequest {
|
||||||
prompt: string
|
prompt: string
|
||||||
bindings?: EnrichedBinding[]
|
bindings?: EnrichedBinding[]
|
||||||
|
@ -8,3 +21,11 @@ export interface GenerateJsRequest {
|
||||||
export interface GenerateJsResponse {
|
export interface GenerateJsResponse {
|
||||||
code: string
|
code: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GenerateCronRequest {
|
||||||
|
prompt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateCronResponse {
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { Document } from "../document"
|
import { Document } from "../document"
|
||||||
|
import { FieldSchema, FormulaType } from "./table"
|
||||||
|
|
||||||
export enum FieldType {
|
export enum FieldType {
|
||||||
/**
|
/**
|
||||||
|
@ -147,6 +148,15 @@ export function isNumeric(type: FieldType) {
|
||||||
return NumericTypes.includes(type)
|
return NumericTypes.includes(type)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isNumericStaticFormula(schema: FieldSchema) {
|
||||||
|
return (
|
||||||
|
schema.type === FieldType.FORMULA &&
|
||||||
|
schema.formulaType === FormulaType.STATIC &&
|
||||||
|
schema.responseType &&
|
||||||
|
isNumeric(schema.responseType)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const GroupByTypes = [
|
export const GroupByTypes = [
|
||||||
FieldType.STRING,
|
FieldType.STRING,
|
||||||
FieldType.LONGFORM,
|
FieldType.LONGFORM,
|
||||||
|
|
|
@ -117,6 +117,7 @@ export type AIProvider =
|
||||||
| "AzureOpenAI"
|
| "AzureOpenAI"
|
||||||
| "TogetherAI"
|
| "TogetherAI"
|
||||||
| "Custom"
|
| "Custom"
|
||||||
|
| "BudibaseAI"
|
||||||
|
|
||||||
export interface ProviderConfig {
|
export interface ProviderConfig {
|
||||||
provider: AIProvider
|
provider: AIProvider
|
||||||
|
|
Loading…
Reference in New Issue