Merge master.
This commit is contained in:
commit
3f3518e4c5
|
@ -1,4 +1,5 @@
|
||||||
import { TableNames } from "@/constants"
|
import { TableNames } from "@/constants"
|
||||||
|
import { INTERNAL_TABLE_SOURCE_ID } from "@budibase/types"
|
||||||
|
|
||||||
const showDatasourceOpen = ({
|
const showDatasourceOpen = ({
|
||||||
selected,
|
selected,
|
||||||
|
@ -41,7 +42,7 @@ const containsActiveEntity = (
|
||||||
// Check for hardcoded datasource edge cases
|
// Check for hardcoded datasource edge cases
|
||||||
if (
|
if (
|
||||||
isActive("./datasource/bb_internal") &&
|
isActive("./datasource/bb_internal") &&
|
||||||
datasource._id === "bb_internal"
|
datasource._id === INTERNAL_TABLE_SOURCE_ID
|
||||||
) {
|
) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,6 +28,7 @@
|
||||||
|
|
||||||
$: isPremiumOrAbove = [
|
$: isPremiumOrAbove = [
|
||||||
Constants.PlanType.PREMIUM,
|
Constants.PlanType.PREMIUM,
|
||||||
|
Constants.PlanType.PREMIUM_PLUS,
|
||||||
Constants.PlanType.ENTERPRISE,
|
Constants.PlanType.ENTERPRISE,
|
||||||
Constants.PlanType.ENTERPRISE_BASIC_TRIAL,
|
Constants.PlanType.ENTERPRISE_BASIC_TRIAL,
|
||||||
Constants.PlanType.ENTERPRISE_BASIC,
|
Constants.PlanType.ENTERPRISE_BASIC,
|
||||||
|
|
|
@ -31,6 +31,8 @@
|
||||||
$: disabled = !aiEnabled || creditsExceeded || readonly || promptLoading
|
$: disabled = !aiEnabled || creditsExceeded || readonly || promptLoading
|
||||||
$: animateBorder = !disabled && expanded
|
$: animateBorder = !disabled && expanded
|
||||||
|
|
||||||
|
$: canSubmit = !readonly && !!value
|
||||||
|
|
||||||
function collapse() {
|
function collapse() {
|
||||||
dispatch("collapse")
|
dispatch("collapse")
|
||||||
expanded = expandedOnly
|
expanded = expandedOnly
|
||||||
|
@ -62,7 +64,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onPromptSubmit() {
|
async function onPromptSubmit() {
|
||||||
if (readonly) {
|
if (!canSubmit) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
promptLoading = true
|
promptLoading = true
|
||||||
|
@ -155,6 +157,7 @@
|
||||||
? "#6E56FF"
|
? "#6E56FF"
|
||||||
: "var(--spectrum-global-color-gray-600)"}
|
: "var(--spectrum-global-color-gray-600)"}
|
||||||
size="S"
|
size="S"
|
||||||
|
disabled={!canSubmit}
|
||||||
hoverable={!readonly}
|
hoverable={!readonly}
|
||||||
hoverColor="#6E56FF"
|
hoverColor="#6E56FF"
|
||||||
name={promptLoading ? "StopCircle" : "PlayCircle"}
|
name={promptLoading ? "StopCircle" : "PlayCircle"}
|
||||||
|
@ -265,6 +268,7 @@
|
||||||
.ai-icon {
|
.ai-icon {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
|
margin-left: 4px;
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
cursor: var(--ai-icon-cursor, pointer);
|
cursor: var(--ai-icon-cursor, pointer);
|
||||||
|
|
|
@ -1,113 +1,5 @@
|
||||||
/**
|
// export * from
|
||||||
* Duplicates a name with respect to a collection of existing names
|
import { helpers } from "@budibase/shared-core"
|
||||||
* e.g.
|
|
||||||
* name all names result
|
|
||||||
* ------ ----------- --------
|
|
||||||
* ("foo") ["foo"] "foo 1"
|
|
||||||
* ("foo") ["foo", "foo 1"] "foo 2"
|
|
||||||
* ("foo 1") ["foo", "foo 1"] "foo 2"
|
|
||||||
* ("foo") ["foo", "foo 2"] "foo 1"
|
|
||||||
*
|
|
||||||
* Repl
|
|
||||||
*/
|
|
||||||
export const duplicateName = (name: string, allNames: string[]) => {
|
|
||||||
const duplicatePattern = new RegExp(`\\s(\\d+)$`)
|
|
||||||
const baseName = name.split(duplicatePattern)[0]
|
|
||||||
const isDuplicate = new RegExp(`${baseName}\\s(\\d+)$`)
|
|
||||||
|
|
||||||
// get the sequence from matched names
|
export const duplicateName = helpers.duplicateName
|
||||||
const sequence: number[] = []
|
export const getSequentialName = helpers.getSequentialName
|
||||||
allNames.filter(n => {
|
|
||||||
if (n === baseName) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
const match = n.match(isDuplicate)
|
|
||||||
if (match) {
|
|
||||||
sequence.push(parseInt(match[1]))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
sequence.sort((a, b) => a - b)
|
|
||||||
// get the next number in the sequence
|
|
||||||
let number
|
|
||||||
if (sequence.length === 0) {
|
|
||||||
number = 1
|
|
||||||
} else {
|
|
||||||
// get the next number in the sequence
|
|
||||||
for (let i = 0; i < sequence.length; i++) {
|
|
||||||
if (sequence[i] !== i + 1) {
|
|
||||||
number = i + 1
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!number) {
|
|
||||||
number = sequence.length + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${baseName} ${number}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* More flexible alternative to the above function, which handles getting the
|
|
||||||
* next sequential name from an array of existing items while accounting for
|
|
||||||
* any type of prefix, and being able to deeply retrieve that name from the
|
|
||||||
* existing item array.
|
|
||||||
*
|
|
||||||
* Examples with a prefix of "foo":
|
|
||||||
* [] => "foo"
|
|
||||||
* ["foo"] => "foo2"
|
|
||||||
* ["foo", "foo6"] => "foo7"
|
|
||||||
*
|
|
||||||
* Examples with a prefix of "foo " (space at the end):
|
|
||||||
* [] => "foo"
|
|
||||||
* ["foo"] => "foo 2"
|
|
||||||
* ["foo", "foo 6"] => "foo 7"
|
|
||||||
*
|
|
||||||
* @param items the array of existing items
|
|
||||||
* @param prefix the string prefix of each name, including any spaces desired
|
|
||||||
* @param getName optional function to extract the name for an item, if not a
|
|
||||||
* flat array of strings
|
|
||||||
*/
|
|
||||||
export const getSequentialName = <T extends any>(
|
|
||||||
items: T[] | null,
|
|
||||||
prefix: string | null,
|
|
||||||
{
|
|
||||||
getName,
|
|
||||||
numberFirstItem,
|
|
||||||
separator = "",
|
|
||||||
}: {
|
|
||||||
getName?: (item: T) => string
|
|
||||||
numberFirstItem?: boolean
|
|
||||||
separator?: string
|
|
||||||
} = {}
|
|
||||||
) => {
|
|
||||||
if (!prefix?.length) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
const trimmedPrefix = prefix.trim()
|
|
||||||
const firstName = numberFirstItem ? `${prefix}1` : trimmedPrefix
|
|
||||||
if (!items?.length) {
|
|
||||||
return firstName
|
|
||||||
}
|
|
||||||
let max = 0
|
|
||||||
items.forEach(item => {
|
|
||||||
const name = getName?.(item) ?? item
|
|
||||||
if (typeof name !== "string" || !name.startsWith(trimmedPrefix)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const split = name.split(trimmedPrefix)
|
|
||||||
if (split.length !== 2) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (split[1].trim() === "") {
|
|
||||||
split[1] = "1"
|
|
||||||
}
|
|
||||||
const num = parseInt(split[1])
|
|
||||||
if (num > max) {
|
|
||||||
max = num
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return max === 0 ? firstName : `${prefix}${separator}${max + 1}`
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,16 +1,32 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { API } from "@/api"
|
import { API } from "@/api"
|
||||||
import AiInput from "@/components/common/ai/AIInput.svelte"
|
import AiInput from "@/components/common/ai/AIInput.svelte"
|
||||||
|
import { datasources, tables } from "@/stores/builder"
|
||||||
import { auth, licensing } from "@/stores/portal"
|
import { auth, licensing } from "@/stores/portal"
|
||||||
import { ActionButton, notifications } from "@budibase/bbui"
|
import { ActionButton, notifications } from "@budibase/bbui"
|
||||||
|
import { goto } from "@roxi/routify"
|
||||||
|
|
||||||
let promptText = ""
|
let promptText = ""
|
||||||
|
|
||||||
$: isEnabled = $auth?.user?.llm && !$licensing.aiCreditsExceeded
|
$: isEnabled = $auth?.user?.llm && !$licensing.aiCreditsExceeded
|
||||||
|
|
||||||
async function submitPrompt(message: string) {
|
async function submitPrompt(message: string) {
|
||||||
await API.generateTables(message)
|
try {
|
||||||
notifications.success("Tables created successfully!")
|
const { createdTables } = await API.generateTables({
|
||||||
|
prompt: message,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [tableToRedirect] = createdTables.sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name)
|
||||||
|
)
|
||||||
|
|
||||||
|
notifications.success(`Tables created successfully.`)
|
||||||
|
await datasources.fetch()
|
||||||
|
await tables.fetch()
|
||||||
|
$goto(`./table/${tableToRedirect.id}`)
|
||||||
|
} catch (e: any) {
|
||||||
|
notifications.error(e.message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const examplePrompts = [
|
const examplePrompts = [
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
let modal
|
let modal
|
||||||
let promptUpload = false
|
let promptUpload = false
|
||||||
|
|
||||||
export function show({ promptUpload: newPromptUpload = false }) {
|
export function show({ promptUpload: newPromptUpload = false } = {}) {
|
||||||
promptUpload = newPromptUpload
|
promptUpload = newPromptUpload
|
||||||
modal.show()
|
modal.show()
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<script>
|
<script lang="ts">
|
||||||
import { API } from "@/api"
|
import { API } from "@/api"
|
||||||
import {
|
import {
|
||||||
tables,
|
tables,
|
||||||
|
@ -19,8 +19,8 @@
|
||||||
import { featureFlag } from "@/helpers"
|
import { featureFlag } from "@/helpers"
|
||||||
import { FeatureFlag } from "@budibase/types"
|
import { FeatureFlag } from "@budibase/types"
|
||||||
|
|
||||||
let internalTableModal
|
let internalTableModal: CreateInternalTableModal
|
||||||
let externalDatasourceModal
|
let externalDatasourceModal: CreateExternalDatasourceModal
|
||||||
|
|
||||||
let sampleDataLoading = false
|
let sampleDataLoading = false
|
||||||
let externalDatasourceLoading = false
|
let externalDatasourceLoading = false
|
||||||
|
@ -72,7 +72,7 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<DatasourceOption
|
<DatasourceOption
|
||||||
on:click={internalTableModal.show}
|
on:click={() => internalTableModal.show()}
|
||||||
title="Create new table"
|
title="Create new table"
|
||||||
description="Non-relational"
|
description="Non-relational"
|
||||||
{disabled}
|
{disabled}
|
||||||
|
|
|
@ -1,11 +1,17 @@
|
||||||
import { GenerateJsRequest, GenerateJsResponse } from "@budibase/types"
|
import {
|
||||||
|
GenerateJsRequest,
|
||||||
|
GenerateJsResponse,
|
||||||
|
GenerateTablesRequest,
|
||||||
|
GenerateTablesResponse,
|
||||||
|
} from "@budibase/types"
|
||||||
import { BaseAPIClient } from "./types"
|
import { BaseAPIClient } from "./types"
|
||||||
import { sleep } from "../utils/utils"
|
|
||||||
|
|
||||||
export interface AIEndpoints {
|
export interface AIEndpoints {
|
||||||
generateCronExpression: (prompt: string) => Promise<{ message: string }>
|
generateCronExpression: (prompt: string) => Promise<{ message: string }>
|
||||||
generateJs: (req: GenerateJsRequest) => Promise<GenerateJsResponse>
|
generateJs: (req: GenerateJsRequest) => Promise<GenerateJsResponse>
|
||||||
generateTables: (prompt: string) => Promise<void>
|
generateTables: (
|
||||||
|
req: GenerateTablesRequest
|
||||||
|
) => Promise<GenerateTablesResponse>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const buildAIEndpoints = (API: BaseAPIClient): AIEndpoints => ({
|
export const buildAIEndpoints = (API: BaseAPIClient): AIEndpoints => ({
|
||||||
|
@ -25,8 +31,11 @@ export const buildAIEndpoints = (API: BaseAPIClient): AIEndpoints => ({
|
||||||
body: req,
|
body: req,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
generateTables: async prompt => {
|
|
||||||
console.warn({ prompt })
|
generateTables: async req => {
|
||||||
await sleep(1000)
|
return await API.post({
|
||||||
|
url: "/api/ai/tables",
|
||||||
|
body: req,
|
||||||
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
@ -86,6 +86,7 @@ export const PlanType = {
|
||||||
PRO: "pro",
|
PRO: "pro",
|
||||||
BUSINESS: "business",
|
BUSINESS: "business",
|
||||||
PREMIUM: "premium",
|
PREMIUM: "premium",
|
||||||
|
PREMIUM_PLUS: "premium_plus",
|
||||||
ENTERPRISE: "enterprise",
|
ENTERPRISE: "enterprise",
|
||||||
ENTERPRISE_BASIC_TRIAL: "enterprise_basic_trial",
|
ENTERPRISE_BASIC_TRIAL: "enterprise_basic_trial",
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,25 @@
|
||||||
|
import {
|
||||||
|
GenerateTablesRequest,
|
||||||
|
GenerateTablesResponse,
|
||||||
|
UserCtx,
|
||||||
|
} from "@budibase/types"
|
||||||
|
import { ai } from "@budibase/pro"
|
||||||
|
import sdk from "../../sdk"
|
||||||
|
|
||||||
|
export async function generateTables(
|
||||||
|
ctx: UserCtx<GenerateTablesRequest, GenerateTablesResponse>
|
||||||
|
) {
|
||||||
|
const { prompt } = ctx.request.body
|
||||||
|
|
||||||
|
const tableGenerator = await ai.TableGeneration.init({
|
||||||
|
generateTablesDelegate: sdk.ai.helpers.generateTables,
|
||||||
|
getTablesDelegate: sdk.tables.getTables,
|
||||||
|
generateDataDelegate: sdk.ai.helpers.generateRows,
|
||||||
|
})
|
||||||
|
tableGenerator.withData(ctx.user._id || "")
|
||||||
|
const createdTables = await tableGenerator.generate(prompt)
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
createdTables,
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
import * as ai from "../controllers/ai"
|
||||||
|
import Router from "@koa/router"
|
||||||
|
import { auth } from "@budibase/backend-core"
|
||||||
|
|
||||||
|
const router: Router = new Router()
|
||||||
|
|
||||||
|
router.post("/api/ai/tables", auth.builderOrAdmin, ai.generateTables)
|
||||||
|
|
||||||
|
export default router
|
|
@ -31,6 +31,7 @@ import { api as pro } from "@budibase/pro"
|
||||||
import rowActionRoutes from "./rowAction"
|
import rowActionRoutes from "./rowAction"
|
||||||
import oauth2Routes from "./oauth2"
|
import oauth2Routes from "./oauth2"
|
||||||
import featuresRoutes from "./features"
|
import featuresRoutes from "./features"
|
||||||
|
import aiRoutes from "./ai"
|
||||||
|
|
||||||
export { default as staticRoutes } from "./static"
|
export { default as staticRoutes } from "./static"
|
||||||
export { default as publicRoutes } from "./public"
|
export { default as publicRoutes } from "./public"
|
||||||
|
@ -76,4 +77,5 @@ export const mainRoutes: Router[] = [
|
||||||
// these need to be handled last as they still use /api/:tableId
|
// these need to be handled last as they still use /api/:tableId
|
||||||
// this could be breaking as koa may recognise other routes as this
|
// this could be breaking as koa may recognise other routes as this
|
||||||
tableRoutes,
|
tableRoutes,
|
||||||
|
aiRoutes,
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,21 +1,25 @@
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
import { zodResponseFormat } from "openai/helpers/zod"
|
||||||
import { mockChatGPTResponse } from "../../../tests/utilities/mocks/ai/openai"
|
import { mockChatGPTResponse } from "../../../tests/utilities/mocks/ai/openai"
|
||||||
import TestConfiguration from "../../../tests/utilities/TestConfiguration"
|
import TestConfiguration from "../../../tests/utilities/TestConfiguration"
|
||||||
import nock from "nock"
|
import nock from "nock"
|
||||||
import { configs, env, features, setEnv } from "@budibase/backend-core"
|
import { configs, env, features, setEnv } from "@budibase/backend-core"
|
||||||
import {
|
import {
|
||||||
AIInnerConfig,
|
AIInnerConfig,
|
||||||
|
AIOperationEnum,
|
||||||
|
AttachmentSubType,
|
||||||
ConfigType,
|
ConfigType,
|
||||||
Feature,
|
Feature,
|
||||||
|
FieldType,
|
||||||
License,
|
License,
|
||||||
PlanModel,
|
PlanModel,
|
||||||
PlanType,
|
PlanType,
|
||||||
ProviderConfig,
|
ProviderConfig,
|
||||||
StructuredOutput,
|
RelationshipType,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { context } from "@budibase/backend-core"
|
import { context } from "@budibase/backend-core"
|
||||||
import { generator, mocks } from "@budibase/backend-core/tests"
|
import { generator, mocks } from "@budibase/backend-core/tests"
|
||||||
import { ai, quotas } from "@budibase/pro"
|
import { quotas, ai } from "@budibase/pro"
|
||||||
import { MockLLMResponseFn } from "../../../tests/utilities/mocks/ai"
|
import { MockLLMResponseFn } from "../../../tests/utilities/mocks/ai"
|
||||||
import { mockAnthropicResponse } from "../../../tests/utilities/mocks/ai/anthropic"
|
import { mockAnthropicResponse } from "../../../tests/utilities/mocks/ai/anthropic"
|
||||||
|
|
||||||
|
@ -414,11 +418,12 @@ describe("BudibaseAI", () => {
|
||||||
expect(usage.monthly.current.budibaseAICredits).toBe(0)
|
expect(usage.monthly.current.budibaseAICredits).toBe(0)
|
||||||
|
|
||||||
const gptResponse = generator.guid()
|
const gptResponse = generator.guid()
|
||||||
const structuredOutput = generator.word() as unknown as StructuredOutput
|
const structuredOutput = zodResponseFormat(
|
||||||
ai.structuredOutputs[structuredOutput] = {
|
z.object({
|
||||||
key: generator.word(),
|
[generator.word()]: z.string(),
|
||||||
validator: z.object({ name: z.string() }),
|
}),
|
||||||
}
|
"key"
|
||||||
|
)
|
||||||
mockChatGPTResponse(gptResponse, { format: structuredOutput })
|
mockChatGPTResponse(gptResponse, { format: structuredOutput })
|
||||||
const { message } = await config.api.ai.chat({
|
const { message } = await config.api.ai.chat({
|
||||||
messages: [{ role: "user", content: "Hello!" }],
|
messages: [{ role: "user", content: "Hello!" }],
|
||||||
|
@ -431,4 +436,492 @@ describe("BudibaseAI", () => {
|
||||||
expect(usage.monthly.current.budibaseAICredits).toBeGreaterThan(0)
|
expect(usage.monthly.current.budibaseAICredits).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("POST /api/ai/tables", () => {
|
||||||
|
let featureCleanup: () => void
|
||||||
|
beforeAll(() => {
|
||||||
|
featureCleanup = features.testutils.setFeatureFlags("*", {
|
||||||
|
AI_TABLE_GENERATION: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
featureCleanup()
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await config.newTenant()
|
||||||
|
nock.cleanAll()
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockAIGenerationStructure = (
|
||||||
|
generationStructure: ai.GenerationStructure
|
||||||
|
) =>
|
||||||
|
mockChatGPTResponse(JSON.stringify(generationStructure), {
|
||||||
|
format: zodResponseFormat(ai.generationStructure, "key"),
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockAIColumnGeneration = (
|
||||||
|
generationStructure: ai.GenerationStructure,
|
||||||
|
aiColumnGeneration: ai.AIColumnSchemas
|
||||||
|
) =>
|
||||||
|
mockChatGPTResponse(JSON.stringify(aiColumnGeneration), {
|
||||||
|
format: zodResponseFormat(
|
||||||
|
ai.aiColumnSchemas(generationStructure),
|
||||||
|
"key"
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockDataGeneration = (
|
||||||
|
dataGeneration: Record<string, Record<string, any>[]>
|
||||||
|
) =>
|
||||||
|
mockChatGPTResponse(JSON.stringify(dataGeneration), {
|
||||||
|
format: zodResponseFormat(ai.tableDataStructuredOutput([]), "key"),
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockProcessAIColumn = (response: string) =>
|
||||||
|
mockChatGPTResponse(response)
|
||||||
|
|
||||||
|
it("handles correct chat response", async () => {
|
||||||
|
const prompt = "Create me a table for managing IT tickets"
|
||||||
|
const generationStructure: ai.GenerationStructure = {
|
||||||
|
tables: [
|
||||||
|
{
|
||||||
|
name: "Tickets",
|
||||||
|
primaryDisplay: "Title",
|
||||||
|
schema: [
|
||||||
|
{
|
||||||
|
name: "Title",
|
||||||
|
type: FieldType.STRING,
|
||||||
|
constraints: {
|
||||||
|
presence: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Description",
|
||||||
|
type: FieldType.LONGFORM,
|
||||||
|
constraints: {
|
||||||
|
presence: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Priority",
|
||||||
|
type: FieldType.OPTIONS,
|
||||||
|
constraints: {
|
||||||
|
inclusion: ["Low", "Medium", "High"],
|
||||||
|
presence: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Status",
|
||||||
|
type: FieldType.OPTIONS,
|
||||||
|
constraints: {
|
||||||
|
inclusion: ["Open", "In Progress", "Closed"],
|
||||||
|
presence: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Assignee",
|
||||||
|
type: FieldType.LINK,
|
||||||
|
tableId: "Employees",
|
||||||
|
relationshipType: RelationshipType.MANY_TO_ONE,
|
||||||
|
reverseFieldName: "AssignedTickets",
|
||||||
|
relationshipId: "TicketUser",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Created Date",
|
||||||
|
type: FieldType.DATETIME,
|
||||||
|
ignoreTimezones: false,
|
||||||
|
dateOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Resolution Time (Days)",
|
||||||
|
type: FieldType.FORMULA,
|
||||||
|
formula:
|
||||||
|
'return (new Date() - new Date($("Created Date"))) / (1000 * 60 * 60 * 24);',
|
||||||
|
responseType: FieldType.NUMBER,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Attachment",
|
||||||
|
type: FieldType.ATTACHMENT_SINGLE,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Employees",
|
||||||
|
primaryDisplay: "First Name",
|
||||||
|
schema: [
|
||||||
|
{
|
||||||
|
name: "First Name",
|
||||||
|
type: FieldType.STRING,
|
||||||
|
constraints: {
|
||||||
|
presence: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Last Name",
|
||||||
|
type: FieldType.STRING,
|
||||||
|
constraints: {
|
||||||
|
presence: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Position",
|
||||||
|
type: FieldType.STRING,
|
||||||
|
constraints: {
|
||||||
|
presence: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Photo",
|
||||||
|
type: FieldType.ATTACHMENT_SINGLE,
|
||||||
|
subtype: AttachmentSubType.IMAGE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Documents",
|
||||||
|
type: FieldType.ATTACHMENTS,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "AssignedTickets",
|
||||||
|
type: FieldType.LINK,
|
||||||
|
tableId: "Tickets",
|
||||||
|
relationshipType: RelationshipType.ONE_TO_MANY,
|
||||||
|
reverseFieldName: "Assignee",
|
||||||
|
relationshipId: "TicketUser",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
mockAIGenerationStructure(generationStructure)
|
||||||
|
|
||||||
|
const aiColumnGeneration: ai.AIColumnSchemas = {
|
||||||
|
Tickets: [
|
||||||
|
{
|
||||||
|
name: "Ticket Summary",
|
||||||
|
type: FieldType.AI,
|
||||||
|
operation: AIOperationEnum.SUMMARISE_TEXT,
|
||||||
|
columns: ["Title", "Description"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Translated Description",
|
||||||
|
type: FieldType.AI,
|
||||||
|
operation: AIOperationEnum.TRANSLATE,
|
||||||
|
column: "Description",
|
||||||
|
language: "es",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
Employees: [
|
||||||
|
{
|
||||||
|
name: "Role Category",
|
||||||
|
type: FieldType.AI,
|
||||||
|
operation: AIOperationEnum.CATEGORISE_TEXT,
|
||||||
|
columns: ["Position"],
|
||||||
|
categories: "Manager,Staff,Intern,Contractor",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
mockAIColumnGeneration(generationStructure, aiColumnGeneration)
|
||||||
|
|
||||||
|
nock("https://photourl.com").get("/any.png").reply(200).persist()
|
||||||
|
|
||||||
|
const dataGeneration: Record<string, Record<string, any>[]> = {
|
||||||
|
Tickets: [
|
||||||
|
{
|
||||||
|
Title: "System slow performance",
|
||||||
|
Description:
|
||||||
|
"User reports significant slowdowns when using multiple applications simultaneously on their PC.",
|
||||||
|
Priority: "Medium",
|
||||||
|
Status: "Closed",
|
||||||
|
"Created Date": "2025-04-17",
|
||||||
|
Attachment: {
|
||||||
|
name: "performance_logs.txt",
|
||||||
|
extension: ".txt",
|
||||||
|
content: "performance logs",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Title: "Email delivery failure",
|
||||||
|
Description:
|
||||||
|
"Emails sent to external clients are bouncing back. Bounce back message: '550: Recipient address rejected'.",
|
||||||
|
Priority: "Medium",
|
||||||
|
Status: "In Progress",
|
||||||
|
"Created Date": "2025-04-19",
|
||||||
|
Attachment: {
|
||||||
|
name: "email_bounce_back.txt",
|
||||||
|
extension: ".txt",
|
||||||
|
content: "Email delivery failure",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Title: "Software installation request",
|
||||||
|
Description:
|
||||||
|
"Request to install Adobe Photoshop on user’s workstation for design work.",
|
||||||
|
Priority: "Low",
|
||||||
|
Status: "In Progress",
|
||||||
|
"Created Date": "2025-04-18",
|
||||||
|
Attachment: {
|
||||||
|
name: "software_request_form.pdf",
|
||||||
|
extension: ".pdf",
|
||||||
|
content: "Software installation request",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Title: "Unable to connect to VPN",
|
||||||
|
Description:
|
||||||
|
"User is experiencing issues when trying to connect to the VPN. Error message: 'VPN connection failed due to incorrect credentials'.",
|
||||||
|
Priority: "High",
|
||||||
|
Status: "Open",
|
||||||
|
"Created Date": "2025-04-20",
|
||||||
|
Attachment: {
|
||||||
|
name: "vpn_error_screenshot.pdf",
|
||||||
|
extension: ".pdf",
|
||||||
|
content: "vpn error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"Employees 2": [
|
||||||
|
{
|
||||||
|
"First Name": "Joshua",
|
||||||
|
"Last Name": "Lee",
|
||||||
|
Position: "Application Developer",
|
||||||
|
Photo: "https://photourl.com/any.png",
|
||||||
|
Documents: [
|
||||||
|
{
|
||||||
|
name: "development_guidelines.pdf",
|
||||||
|
extension: ".pdf",
|
||||||
|
content: "any content",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "project_documents.txt",
|
||||||
|
extension: ".txt",
|
||||||
|
content: "any content",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"First Name": "Emily",
|
||||||
|
"Last Name": "Davis",
|
||||||
|
Position: "Software Deployment Technician",
|
||||||
|
Photo: "https://photourl.com/any.png",
|
||||||
|
Documents: [
|
||||||
|
{
|
||||||
|
name: "software_license_list.txt",
|
||||||
|
extension: ".txt",
|
||||||
|
content: "any content",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deployment_guide.pdf",
|
||||||
|
extension: ".pdf",
|
||||||
|
content: "any content",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "installation_logs.txt",
|
||||||
|
extension: ".txt",
|
||||||
|
content: "any content",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"First Name": "James",
|
||||||
|
"Last Name": "Smith",
|
||||||
|
Position: "IT Support Specialist",
|
||||||
|
Photo: "https://photourl.com/any.png",
|
||||||
|
Documents: [
|
||||||
|
{
|
||||||
|
name: "certificates.pdf",
|
||||||
|
extension: ".pdf",
|
||||||
|
content: "any content",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "employment_contract.pdf",
|
||||||
|
extension: ".pdf",
|
||||||
|
content: "any content",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"First Name": "Jessica",
|
||||||
|
"Last Name": "Taylor",
|
||||||
|
Position: "Cybersecurity Analyst",
|
||||||
|
Photo: "https://photourl.com/any.png",
|
||||||
|
Documents: [
|
||||||
|
{
|
||||||
|
name: "security_audit_report.pdf",
|
||||||
|
extension: ".pdf",
|
||||||
|
content: "any content",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "incident_response_plan.pdf",
|
||||||
|
extension: ".pdf",
|
||||||
|
content: "any content",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"First Name": "Ashley",
|
||||||
|
"Last Name": "Harris",
|
||||||
|
Position: "Database Administrator",
|
||||||
|
Photo: "https://photourl.com/any.png",
|
||||||
|
Documents: [
|
||||||
|
{
|
||||||
|
name: "database_backup.txt",
|
||||||
|
extension: ".txt",
|
||||||
|
content: "any content",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "permission_settings.pdf",
|
||||||
|
extension: ".pdf",
|
||||||
|
content: "any content",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
mockDataGeneration(dataGeneration)
|
||||||
|
|
||||||
|
mockProcessAIColumn("Mock LLM Response")
|
||||||
|
|
||||||
|
const { createdTables } = await config.api.ai.generateTables({ prompt })
|
||||||
|
expect(createdTables).toEqual([
|
||||||
|
{ id: expect.stringMatching(/ta_\w+/), name: "Tickets" },
|
||||||
|
{ id: expect.stringMatching(/ta_\w+/), name: "Employees" },
|
||||||
|
])
|
||||||
|
|
||||||
|
const tables = [
|
||||||
|
await config.api.table.get(createdTables[0].id),
|
||||||
|
await config.api.table.get(createdTables[1].id),
|
||||||
|
]
|
||||||
|
expect(tables).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "Tickets",
|
||||||
|
schema: {
|
||||||
|
Title: {
|
||||||
|
name: "Title",
|
||||||
|
type: "string",
|
||||||
|
constraints: {
|
||||||
|
presence: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Description: {
|
||||||
|
name: "Description",
|
||||||
|
type: "longform",
|
||||||
|
constraints: {
|
||||||
|
presence: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Priority: {
|
||||||
|
name: "Priority",
|
||||||
|
type: "options",
|
||||||
|
constraints: {
|
||||||
|
inclusion: ["Low", "Medium", "High"],
|
||||||
|
presence: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Status: {
|
||||||
|
name: "Status",
|
||||||
|
type: "options",
|
||||||
|
constraints: {
|
||||||
|
inclusion: ["Open", "In Progress", "Closed"],
|
||||||
|
presence: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Assignee: {
|
||||||
|
name: "Assignee",
|
||||||
|
type: "link",
|
||||||
|
tableId: createdTables[1].id,
|
||||||
|
fieldName: "AssignedTickets",
|
||||||
|
relationshipType: "one-to-many",
|
||||||
|
},
|
||||||
|
"Created Date": {
|
||||||
|
name: "Created Date",
|
||||||
|
type: "datetime",
|
||||||
|
ignoreTimezones: false,
|
||||||
|
dateOnly: true,
|
||||||
|
},
|
||||||
|
"Resolution Time (Days)": {
|
||||||
|
name: "Resolution Time (Days)",
|
||||||
|
type: "formula",
|
||||||
|
formula:
|
||||||
|
'{{ js "cmV0dXJuIChuZXcgRGF0ZSgpIC0gbmV3IERhdGUoJCgiQ3JlYXRlZCBEYXRlIikpKSAvICgxMDAwICogNjAgKiA2MCAqIDI0KTs=" }}',
|
||||||
|
responseType: "number",
|
||||||
|
},
|
||||||
|
Attachment: {
|
||||||
|
name: "Attachment",
|
||||||
|
type: "attachment_single",
|
||||||
|
},
|
||||||
|
"Ticket Summary": {
|
||||||
|
name: "Ticket Summary",
|
||||||
|
type: "ai",
|
||||||
|
operation: "SUMMARISE_TEXT",
|
||||||
|
columns: ["Title", "Description"],
|
||||||
|
},
|
||||||
|
"Translated Description": {
|
||||||
|
name: "Translated Description",
|
||||||
|
type: "ai",
|
||||||
|
operation: "TRANSLATE",
|
||||||
|
column: "Description",
|
||||||
|
language: "es",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "Employees 2",
|
||||||
|
schema: {
|
||||||
|
"First Name": {
|
||||||
|
constraints: {
|
||||||
|
presence: true,
|
||||||
|
},
|
||||||
|
name: "First Name",
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
"Last Name": {
|
||||||
|
constraints: {
|
||||||
|
presence: true,
|
||||||
|
},
|
||||||
|
name: "Last Name",
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
Photo: {
|
||||||
|
name: "Photo",
|
||||||
|
subtype: "image",
|
||||||
|
type: "attachment_single",
|
||||||
|
},
|
||||||
|
Position: {
|
||||||
|
constraints: {
|
||||||
|
presence: true,
|
||||||
|
},
|
||||||
|
name: "Position",
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
AssignedTickets: {
|
||||||
|
fieldName: "Assignee",
|
||||||
|
name: "AssignedTickets",
|
||||||
|
relationshipType: "many-to-one",
|
||||||
|
tableId: createdTables[0].id,
|
||||||
|
type: "link",
|
||||||
|
},
|
||||||
|
Documents: {
|
||||||
|
name: "Documents",
|
||||||
|
type: "attachment",
|
||||||
|
},
|
||||||
|
"Role Category": {
|
||||||
|
categories: "Manager,Staff,Intern,Contractor",
|
||||||
|
columns: ["Position"],
|
||||||
|
name: "Role Category",
|
||||||
|
operation: "CATEGORISE_TEXT",
|
||||||
|
type: "ai",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const tickets = await config.api.row.fetch(createdTables[0].id)
|
||||||
|
expect(tickets).toHaveLength(4)
|
||||||
|
|
||||||
|
const employees = await config.api.row.fetch(createdTables[1].id)
|
||||||
|
expect(employees).toHaveLength(5)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./table"
|
||||||
|
export * from "./rows"
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { FieldSchema, FieldType, Table } from "@budibase/types"
|
||||||
|
import sdk from "../../.."
|
||||||
|
import { uploadFile, uploadUrl } from "../../../../utilities"
|
||||||
|
|
||||||
|
export async function generateRows(
|
||||||
|
data: Record<string, Record<string, any>[]>,
|
||||||
|
userId: string,
|
||||||
|
tables: Record<string, Table>
|
||||||
|
) {
|
||||||
|
const createdData: Record<string, Record<string, string>> = {}
|
||||||
|
const toUpdateLinks: {
|
||||||
|
tableId: string
|
||||||
|
rowId: string
|
||||||
|
data: Record<string, { rowId: string[]; tableId: string }>
|
||||||
|
}[] = []
|
||||||
|
for (const tableName of Object.keys(data)) {
|
||||||
|
const table = tables[tableName]
|
||||||
|
const linksOverride: Record<string, null> = {}
|
||||||
|
for (const field of Object.values(table.schema).filter(
|
||||||
|
f => f.type === FieldType.LINK
|
||||||
|
)) {
|
||||||
|
linksOverride[field.name] = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachmentColumns = Object.values(table.schema).filter(f =>
|
||||||
|
[FieldType.ATTACHMENTS, FieldType.ATTACHMENT_SINGLE].includes(f.type)
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const entry of data[tableName]) {
|
||||||
|
await processAttachments(entry, attachmentColumns)
|
||||||
|
|
||||||
|
const tableId = table._id!
|
||||||
|
const createdRow = await sdk.rows.save(
|
||||||
|
tableId,
|
||||||
|
{
|
||||||
|
...entry,
|
||||||
|
...linksOverride,
|
||||||
|
_id: undefined,
|
||||||
|
},
|
||||||
|
userId
|
||||||
|
)
|
||||||
|
|
||||||
|
createdData[tableId] ??= {}
|
||||||
|
createdData[tableId][entry._id] = createdRow.row._id!
|
||||||
|
|
||||||
|
const overridenLinks = Object.keys(linksOverride).reduce<
|
||||||
|
Record<string, { rowId: string[]; tableId: string }>
|
||||||
|
>((acc, l) => {
|
||||||
|
if (entry[l]) {
|
||||||
|
acc[l] = {
|
||||||
|
tableId: (table.schema[l] as any).tableId,
|
||||||
|
rowId: entry[l],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
if (Object.keys(overridenLinks).length) {
|
||||||
|
toUpdateLinks.push({
|
||||||
|
tableId: createdRow.table._id!,
|
||||||
|
rowId: createdRow.row._id!,
|
||||||
|
data: overridenLinks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const data of toUpdateLinks) {
|
||||||
|
const persistedRow = await sdk.rows.find(data.tableId, data.rowId)
|
||||||
|
|
||||||
|
const updatedLinks = Object.keys(data.data).reduce<Record<string, any>>(
|
||||||
|
(acc, d) => {
|
||||||
|
acc[d] = [
|
||||||
|
...(persistedRow[d] || []),
|
||||||
|
...data.data[d].rowId.map(
|
||||||
|
rid => createdData[data.data[d].tableId][rid]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
|
||||||
|
await sdk.rows.save(
|
||||||
|
data.tableId,
|
||||||
|
{
|
||||||
|
...persistedRow,
|
||||||
|
...updatedLinks,
|
||||||
|
},
|
||||||
|
userId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function processAttachments(
|
||||||
|
entry: Record<string, any>,
|
||||||
|
attachmentColumns: FieldSchema[]
|
||||||
|
) {
|
||||||
|
function processAttachment(value: any) {
|
||||||
|
if (typeof value === "object") {
|
||||||
|
return uploadFile(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return uploadUrl(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const column of attachmentColumns) {
|
||||||
|
if (!Array.isArray(entry[column.name])) {
|
||||||
|
entry[column.name] = await processAttachment(entry[column.name])
|
||||||
|
} else {
|
||||||
|
entry[column.name] = await Promise.all(
|
||||||
|
entry[column.name].map((attachment: any) =>
|
||||||
|
processAttachment(attachment)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,80 @@
|
||||||
|
import {
|
||||||
|
FieldType,
|
||||||
|
GenerateTablesResponse,
|
||||||
|
INTERNAL_TABLE_SOURCE_ID,
|
||||||
|
TableSchema,
|
||||||
|
TableSourceType,
|
||||||
|
} from "@budibase/types"
|
||||||
|
import sdk from "../../.."
|
||||||
|
import { helpers } from "@budibase/shared-core"
|
||||||
|
|
||||||
|
export async function generateTables(
|
||||||
|
tables: { name: string; primaryDisplay: string; schema: TableSchema }[]
|
||||||
|
) {
|
||||||
|
const createdTables: GenerateTablesResponse["createdTables"] = []
|
||||||
|
const tableIds: Record<string, string> = {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const table of tables) {
|
||||||
|
for (const linkField of Object.values(table.schema).filter(
|
||||||
|
f => f.type === FieldType.LINK
|
||||||
|
)) {
|
||||||
|
if (!tables.find(t => t.name === linkField.tableId)) {
|
||||||
|
throw `Table ${linkField.tableId} not found in the json response.`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingTableNames = (await sdk.tables.getAllInternalTables()).map(
|
||||||
|
t => t.name
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const table of tables) {
|
||||||
|
const name = helpers.getSequentialName(existingTableNames, table.name, {
|
||||||
|
separator: " ",
|
||||||
|
})
|
||||||
|
const createdTable = await sdk.tables.create({
|
||||||
|
...table,
|
||||||
|
name,
|
||||||
|
schema: {},
|
||||||
|
primaryDisplay: undefined,
|
||||||
|
sourceType: TableSourceType.INTERNAL,
|
||||||
|
sourceId: INTERNAL_TABLE_SOURCE_ID,
|
||||||
|
type: "table",
|
||||||
|
})
|
||||||
|
|
||||||
|
createdTables.push({ id: createdTable._id!, name: table.name })
|
||||||
|
tableIds[table.name] = createdTable._id!
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const table of tables) {
|
||||||
|
for (const field of Object.values(table.schema)) {
|
||||||
|
if (field.type === FieldType.LINK) {
|
||||||
|
field.tableId = tableIds[field.tableId]
|
||||||
|
} else if (field.type === FieldType.FORMULA) {
|
||||||
|
field.formula = `{{ js "${btoa(field.formula)}" }}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const table of tables) {
|
||||||
|
const storedTable = await sdk.tables.getTable(tableIds[table.name])
|
||||||
|
|
||||||
|
await sdk.tables.update({
|
||||||
|
...storedTable,
|
||||||
|
schema: {
|
||||||
|
...storedTable.schema,
|
||||||
|
...table.schema,
|
||||||
|
},
|
||||||
|
primaryDisplay: table.primaryDisplay,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const tables = await sdk.tables.getTables(createdTables.map(t => t.id))
|
||||||
|
await Promise.all(tables.map(sdk.tables.internal.destroy))
|
||||||
|
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdTables
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export * as helpers from "./helpers"
|
|
@ -11,6 +11,7 @@ import {
|
||||||
Row,
|
Row,
|
||||||
RestConfig,
|
RestConfig,
|
||||||
SourceName,
|
SourceName,
|
||||||
|
INTERNAL_TABLE_SOURCE_ID,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { cloneDeep } from "lodash/fp"
|
import { cloneDeep } from "lodash/fp"
|
||||||
import { getEnvironmentVariables } from "../../utils"
|
import { getEnvironmentVariables } from "../../utils"
|
||||||
|
@ -51,7 +52,7 @@ export async function fetch(opts?: {
|
||||||
)
|
)
|
||||||
|
|
||||||
const internal = internalTables.rows.reduce((acc: any, row: Row) => {
|
const internal = internalTables.rows.reduce((acc: any, row: Row) => {
|
||||||
const sourceId = row.doc.sourceId || "bb_internal"
|
const sourceId = row.doc.sourceId || INTERNAL_TABLE_SOURCE_ID
|
||||||
acc[sourceId] = acc[sourceId] || []
|
acc[sourceId] = acc[sourceId] || []
|
||||||
acc[sourceId].push(row.doc)
|
acc[sourceId].push(row.doc)
|
||||||
return acc
|
return acc
|
||||||
|
|
|
@ -14,6 +14,7 @@ import * as rowActions from "./app/rowActions"
|
||||||
import * as screens from "./app/screens"
|
import * as screens from "./app/screens"
|
||||||
import * as common from "./app/common"
|
import * as common from "./app/common"
|
||||||
import * as oauth2 from "./app/oauth2"
|
import * as oauth2 from "./app/oauth2"
|
||||||
|
import * as ai from "./app/ai"
|
||||||
|
|
||||||
const sdk = {
|
const sdk = {
|
||||||
backups,
|
backups,
|
||||||
|
@ -32,6 +33,7 @@ const sdk = {
|
||||||
rowActions,
|
rowActions,
|
||||||
common,
|
common,
|
||||||
oauth2,
|
oauth2,
|
||||||
|
ai,
|
||||||
}
|
}
|
||||||
|
|
||||||
// default export for TS
|
// default export for TS
|
||||||
|
|
|
@ -5,6 +5,8 @@ import {
|
||||||
GenerateCronResponse,
|
GenerateCronResponse,
|
||||||
GenerateJsRequest,
|
GenerateJsRequest,
|
||||||
GenerateJsResponse,
|
GenerateJsResponse,
|
||||||
|
GenerateTablesRequest,
|
||||||
|
GenerateTablesResponse,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { Expectations, TestAPI } from "./base"
|
import { Expectations, TestAPI } from "./base"
|
||||||
import { constants } from "@budibase/backend-core"
|
import { constants } from "@budibase/backend-core"
|
||||||
|
@ -44,4 +46,16 @@ export class AIAPI extends TestAPI {
|
||||||
expectations,
|
expectations,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
generateTables = async (
|
||||||
|
req: GenerateTablesRequest,
|
||||||
|
expectations?: Expectations
|
||||||
|
): Promise<GenerateTablesResponse> => {
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
return await this._post<GenerateTablesResponse>(`/api/ai/tables`, {
|
||||||
|
body: req,
|
||||||
|
headers,
|
||||||
|
expectations,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,85 @@
|
||||||
|
import fs from "fs"
|
||||||
|
import path from "path"
|
||||||
|
import { pipeline } from "stream"
|
||||||
|
import { promisify } from "util"
|
||||||
|
import * as uuid from "uuid"
|
||||||
|
import fetch from "node-fetch"
|
||||||
|
|
||||||
|
import { context, objectStore } from "@budibase/backend-core"
|
||||||
|
import { Upload } from "@budibase/types"
|
||||||
|
import { ObjectStoreBuckets } from "../constants"
|
||||||
|
|
||||||
|
function getTmpPath() {
|
||||||
|
const tmpPath = path.join(objectStore.budibaseTempDir(), "ai-downloads")
|
||||||
|
if (!fs.existsSync(tmpPath)) {
|
||||||
|
fs.mkdirSync(tmpPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmpPath
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadUrl(url: string): Promise<Upload | undefined> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url)
|
||||||
|
|
||||||
|
const extension = [...res.url.split(".")].pop()!.split("?")[0]
|
||||||
|
|
||||||
|
const destination = path.resolve(getTmpPath(), `${uuid.v4()}${extension}`)
|
||||||
|
const fileStream = fs.createWriteStream(destination, { flags: "wx" })
|
||||||
|
|
||||||
|
await promisify(pipeline)(res.body, fileStream)
|
||||||
|
|
||||||
|
const processedFileName = path.basename(destination)
|
||||||
|
|
||||||
|
const s3Key = `${context.getProdAppId()}/attachments/${processedFileName}`
|
||||||
|
|
||||||
|
const response = await objectStore.upload({
|
||||||
|
bucket: ObjectStoreBuckets.APPS,
|
||||||
|
filename: s3Key,
|
||||||
|
path: destination,
|
||||||
|
type: "image/jpeg",
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
size: fileStream.bytesWritten,
|
||||||
|
name: processedFileName,
|
||||||
|
url: await objectStore.getAppFileUrl(s3Key),
|
||||||
|
extension,
|
||||||
|
key: response.Key!,
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error downloading file", e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFile(file: {
|
||||||
|
fileName: string
|
||||||
|
extension: string
|
||||||
|
content: string
|
||||||
|
}): Promise<Upload> {
|
||||||
|
const destination = path.resolve(
|
||||||
|
getTmpPath(),
|
||||||
|
`${file.fileName}${file.extension}`
|
||||||
|
)
|
||||||
|
|
||||||
|
fs.writeFileSync(destination, file.content)
|
||||||
|
|
||||||
|
const processedFileName = path.basename(destination)
|
||||||
|
const s3Key = `${context.getProdAppId()}/attachments/${processedFileName}`
|
||||||
|
|
||||||
|
const response = await objectStore.upload({
|
||||||
|
bucket: ObjectStoreBuckets.APPS,
|
||||||
|
filename: s3Key,
|
||||||
|
path: destination,
|
||||||
|
type: "text/plain",
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
size: fs.readFileSync(destination).byteLength,
|
||||||
|
name: processedFileName,
|
||||||
|
url: await objectStore.getAppFileUrl(s3Key),
|
||||||
|
extension: file.extension,
|
||||||
|
key: response.Key!,
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,3 +1,4 @@
|
||||||
|
export * from "./fileUtils"
|
||||||
import env from "../environment"
|
import env from "../environment"
|
||||||
import { context } from "@budibase/backend-core"
|
import { context } from "@budibase/backend-core"
|
||||||
import { generateMetadataID } from "../db/utils"
|
import { generateMetadataID } from "../db/utils"
|
||||||
|
|
|
@ -0,0 +1,113 @@
|
||||||
|
/**
|
||||||
|
* Duplicates a name with respect to a collection of existing names
|
||||||
|
* e.g.
|
||||||
|
* name all names result
|
||||||
|
* ------ ----------- --------
|
||||||
|
* ("foo") ["foo"] "foo 1"
|
||||||
|
* ("foo") ["foo", "foo 1"] "foo 2"
|
||||||
|
* ("foo 1") ["foo", "foo 1"] "foo 2"
|
||||||
|
* ("foo") ["foo", "foo 2"] "foo 1"
|
||||||
|
*
|
||||||
|
* Repl
|
||||||
|
*/
|
||||||
|
export const duplicateName = (name: string, allNames: string[]) => {
|
||||||
|
const duplicatePattern = new RegExp(`\\s(\\d+)$`)
|
||||||
|
const baseName = name.split(duplicatePattern)[0]
|
||||||
|
const isDuplicate = new RegExp(`${baseName}\\s(\\d+)$`)
|
||||||
|
|
||||||
|
// get the sequence from matched names
|
||||||
|
const sequence: number[] = []
|
||||||
|
allNames.filter(n => {
|
||||||
|
if (n === baseName) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const match = n.match(isDuplicate)
|
||||||
|
if (match) {
|
||||||
|
sequence.push(parseInt(match[1]))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
sequence.sort((a, b) => a - b)
|
||||||
|
// get the next number in the sequence
|
||||||
|
let number
|
||||||
|
if (sequence.length === 0) {
|
||||||
|
number = 1
|
||||||
|
} else {
|
||||||
|
// get the next number in the sequence
|
||||||
|
for (let i = 0; i < sequence.length; i++) {
|
||||||
|
if (sequence[i] !== i + 1) {
|
||||||
|
number = i + 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!number) {
|
||||||
|
number = sequence.length + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${baseName} ${number}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* More flexible alternative to the above function, which handles getting the
|
||||||
|
* next sequential name from an array of existing items while accounting for
|
||||||
|
* any type of prefix, and being able to deeply retrieve that name from the
|
||||||
|
* existing item array.
|
||||||
|
*
|
||||||
|
* Examples with a prefix of "foo":
|
||||||
|
* [] => "foo"
|
||||||
|
* ["foo"] => "foo2"
|
||||||
|
* ["foo", "foo6"] => "foo7"
|
||||||
|
*
|
||||||
|
* Examples with a prefix of "foo " (space at the end):
|
||||||
|
* [] => "foo"
|
||||||
|
* ["foo"] => "foo 2"
|
||||||
|
* ["foo", "foo 6"] => "foo 7"
|
||||||
|
*
|
||||||
|
* @param items the array of existing items
|
||||||
|
* @param prefix the string prefix of each name, including any spaces desired
|
||||||
|
* @param getName optional function to extract the name for an item, if not a
|
||||||
|
* flat array of strings
|
||||||
|
*/
|
||||||
|
export const getSequentialName = <T extends any>(
|
||||||
|
items: T[] | null,
|
||||||
|
prefix: string | null,
|
||||||
|
{
|
||||||
|
getName,
|
||||||
|
numberFirstItem,
|
||||||
|
separator = "",
|
||||||
|
}: {
|
||||||
|
getName?: (item: T) => string
|
||||||
|
numberFirstItem?: boolean
|
||||||
|
separator?: string
|
||||||
|
} = {}
|
||||||
|
) => {
|
||||||
|
if (!prefix?.length) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
const trimmedPrefix = prefix.trim()
|
||||||
|
const firstName = numberFirstItem ? `${prefix}1` : trimmedPrefix
|
||||||
|
if (!items?.length) {
|
||||||
|
return firstName
|
||||||
|
}
|
||||||
|
let max = 0
|
||||||
|
items.forEach(item => {
|
||||||
|
const name = getName?.(item) ?? item
|
||||||
|
if (typeof name !== "string" || !name.startsWith(trimmedPrefix)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const split = name.split(trimmedPrefix)
|
||||||
|
if (split.length !== 2) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (split[1].trim() === "") {
|
||||||
|
split[1] = "1"
|
||||||
|
}
|
||||||
|
const num = parseInt(split[1])
|
||||||
|
if (num > max) {
|
||||||
|
max = num
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return max === 0 ? firstName : `${prefix}${separator}${max + 1}`
|
||||||
|
}
|
|
@ -7,3 +7,4 @@ export * as schema from "./schema"
|
||||||
export * as views from "./views"
|
export * as views from "./views"
|
||||||
export * as roles from "./roles"
|
export * as roles from "./roles"
|
||||||
export * as lists from "./lists"
|
export * as lists from "./lists"
|
||||||
|
export * from "./duplicate"
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { expect, describe, it } from "vitest"
|
|
||||||
import { duplicateName, getSequentialName } from "../duplicate"
|
import { duplicateName, getSequentialName } from "../duplicate"
|
||||||
|
|
||||||
describe("duplicate", () => {
|
describe("duplicate", () => {
|
|
@ -1,3 +1,4 @@
|
||||||
|
import openai from "openai"
|
||||||
import { EnrichedBinding } from "../../ui"
|
import { EnrichedBinding } from "../../ui"
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
|
@ -5,9 +6,7 @@ export interface Message {
|
||||||
content: string
|
content: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum StructuredOutput {}
|
export type ResponseFormat = "text" | "json" | openai.ResponseFormatJSONSchema
|
||||||
|
|
||||||
export type ResponseFormat = "text" | "json" | StructuredOutput
|
|
||||||
|
|
||||||
export interface ChatCompletionRequest {
|
export interface ChatCompletionRequest {
|
||||||
messages: Message[]
|
messages: Message[]
|
||||||
|
@ -35,3 +34,11 @@ export interface GenerateCronRequest {
|
||||||
export interface GenerateCronResponse {
|
export interface GenerateCronResponse {
|
||||||
message?: string
|
message?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GenerateTablesRequest {
|
||||||
|
prompt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateTablesResponse {
|
||||||
|
createdTables: { id: string; name: string }[]
|
||||||
|
}
|
||||||
|
|
|
@ -14,6 +14,7 @@ export type FieldSubType =
|
||||||
| AutoFieldSubType
|
| AutoFieldSubType
|
||||||
| JsonFieldSubType
|
| JsonFieldSubType
|
||||||
| BBReferenceFieldSubType
|
| BBReferenceFieldSubType
|
||||||
|
| AttachmentSubType
|
||||||
|
|
||||||
export enum AutoFieldSubType {
|
export enum AutoFieldSubType {
|
||||||
CREATED_BY = "createdBy",
|
CREATED_BY = "createdBy",
|
||||||
|
@ -39,6 +40,10 @@ export enum BBReferenceFieldSubType {
|
||||||
USERS = "users",
|
USERS = "users",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum AttachmentSubType {
|
||||||
|
IMAGE = "image",
|
||||||
|
}
|
||||||
|
|
||||||
export type SupportedSqlTypes =
|
export type SupportedSqlTypes =
|
||||||
| FieldType.STRING
|
| FieldType.STRING
|
||||||
| FieldType.BARCODEQR
|
| FieldType.BARCODEQR
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
// column size, position and whether it can be viewed
|
// column size, position and whether it can be viewed
|
||||||
import { FieldType, FormulaResponseType } from "../row"
|
import { FieldType, FormulaResponseType } from "../row"
|
||||||
import {
|
import {
|
||||||
|
AttachmentSubType,
|
||||||
AutoFieldSubType,
|
AutoFieldSubType,
|
||||||
AutoReason,
|
AutoReason,
|
||||||
BBReferenceFieldSubType,
|
BBReferenceFieldSubType,
|
||||||
|
@ -142,8 +143,15 @@ export interface BBReferenceSingleFieldMetadata
|
||||||
default?: string
|
default?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AttachmentFieldMetadata extends BaseFieldSchema {
|
export interface AttachmentFieldMetadata
|
||||||
|
extends Omit<BaseFieldSchema, "subtype"> {
|
||||||
type: FieldType.ATTACHMENTS
|
type: FieldType.ATTACHMENTS
|
||||||
|
subtype?: AttachmentSubType
|
||||||
|
}
|
||||||
|
export interface SingleAttachmentFieldMetadata
|
||||||
|
extends Omit<BaseFieldSchema, "subtype"> {
|
||||||
|
type: FieldType.ATTACHMENT_SINGLE
|
||||||
|
subtype?: AttachmentSubType
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FieldConstraints {
|
export interface FieldConstraints {
|
||||||
|
@ -246,6 +254,7 @@ export type FieldSchema =
|
||||||
| BBReferenceFieldMetadata
|
| BBReferenceFieldMetadata
|
||||||
| JsonFieldMetadata
|
| JsonFieldMetadata
|
||||||
| AttachmentFieldMetadata
|
| AttachmentFieldMetadata
|
||||||
|
| SingleAttachmentFieldMetadata
|
||||||
| BBReferenceSingleFieldMetadata
|
| BBReferenceSingleFieldMetadata
|
||||||
| ArrayFieldMetadata
|
| ArrayFieldMetadata
|
||||||
| OptionsFieldMetadata
|
| OptionsFieldMetadata
|
||||||
|
|
Loading…
Reference in New Issue