Skip to content

Commit 0a50a04

Browse files
authored
Merge pull request #5956 from FlowFuse/add-api-o-generate-snapshot-diff-description
Add api to generate snapshot diff description
2 parents 222a7d0 + 3388eac commit 0a50a04

File tree

10 files changed

+592
-19
lines changed

10 files changed

+592
-19
lines changed

forge/db/controllers/Assistant.js

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
const { default: axios } = require('axios')
2+
3+
module.exports = {
4+
/**
5+
* Invokes a Language Learning Model (LLM) service with the specified method and payload.
6+
*
7+
* @async
8+
* @function
9+
* @param {Object} app @ignore - The application instance containing configuration details.
10+
* @param {string} method - The API endpoint method to call on the LLM service.
11+
* @param {Object} payload - The request payload to be sent to the LLM.
12+
* @param {Object} options - An optional object containing configurations for the request.
13+
* @param {string} options.teamHashId - The unique identifier for the team.
14+
* @param {Object} [options.additionalHeaders={}] - Additional headers to be included in the request.
15+
* @param {string} options.instanceType - The type of instance making the request.
16+
* @param {string} options.instanceId - The unique identifier for the instance.
17+
* @param {boolean} [options.isTeamOnTrial] - Indicator if the team is currently on trial.
18+
* @returns {Promise<Object>} Resolves with the response data from the LLM service.
19+
* @throws {Error} Throws an error if there is a transaction ID mismatch in the response.
20+
*/
21+
invokeLLM: async (app, method, payload, {
22+
teamHashId,
23+
additionalHeaders = { },
24+
instanceType,
25+
instanceId,
26+
isTeamOnTrial = undefined
27+
}) => {
28+
const timeout = app.config.assistant?.service?.requestTimeout || 60000
29+
const serviceUrl = app.config.assistant?.service?.url
30+
const url = `${serviceUrl.replace(/\/+$/, '')}/${method.replace(/^\/+/, '')}`
31+
32+
const headers = await module.exports.buildRequestHeaders(app, additionalHeaders, {
33+
isTeamOnTrial,
34+
instanceType,
35+
instanceId,
36+
teamHashId
37+
})
38+
39+
const response = await axios.post(url, payload, { headers, timeout })
40+
41+
if (payload.transactionId !== response.data.transactionId) {
42+
throw new Error('Transaction ID mismatch') // Ensure we are responding to the correct transaction
43+
}
44+
return response.data
45+
},
46+
47+
/**
48+
* Builds and returns the request headers for HTTP requests, combining application configuration, license information,
49+
* and additional headers as needed.
50+
*
51+
* @param {Object} app - The application object containing configuration and license details.
52+
* @param {Object} additionalHeaders - An object containing additional headers to include in the request.
53+
* @param {Object} options - Options for customizing the headers.
54+
* @param {string} options.instanceType - The type of owner for the request (e.g., user, team).
55+
* @param {string} options.instanceId - The instance ID associated with the request.
56+
* @param {string} options.teamHashId - The hashed team ID associated with the request.
57+
* @param {boolean} [options.isTeamOnTrial] - Indicates if the team is currently in a trial period.
58+
* @returns {Promise<Object>} A Promise */
59+
buildRequestHeaders: async (app, additionalHeaders, { instanceType, instanceId, teamHashId, isTeamOnTrial }) => {
60+
const config = app.config
61+
const serviceToken = config.assistant?.service?.token
62+
63+
const requestHeaders = {
64+
'ff-owner-type': instanceType,
65+
'ff-owner-id': instanceId,
66+
'ff-team-id': teamHashId
67+
}
68+
// include license information, team id and trial status so that we can make decisions in the assistant service
69+
const isLicensed = app.license?.active() || false
70+
const licenseType = isLicensed ? (app.license.get('dev') ? 'DEV' : 'EE') : 'CE'
71+
const tier = isLicensed ? app.license.get('tier') : null
72+
73+
requestHeaders['ff-license-active'] = isLicensed
74+
requestHeaders['ff-license-type'] = licenseType
75+
requestHeaders['ff-license-tier'] = tier
76+
77+
if (isTeamOnTrial !== undefined) {
78+
requestHeaders['ff-team-trial'] = isTeamOnTrial
79+
}
80+
if (serviceToken) {
81+
requestHeaders.Authorization = `Bearer ${serviceToken}`
82+
}
83+
if (additionalHeaders.accept) {
84+
requestHeaders.Accept = additionalHeaders.accept
85+
}
86+
if (additionalHeaders['user-agent']) {
87+
requestHeaders['User-Agent'] = additionalHeaders['user-agent']
88+
}
89+
90+
return requestHeaders
91+
}
92+
}

forge/db/controllers/ProjectSnapshot.js

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -351,14 +351,7 @@ Object.freeze(deviceAutoSnapshotUtils)
351351
Object.freeze(instanceAutoSnapshotUtils)
352352

353353
module.exports = {
354-
/**
355-
* Creates a snapshot of the current state of a project.
356-
* Patches with flows, credentials, settings modules and env from request, if provided
357-
*
358-
* @param {*} app
359-
* @param {*} project
360-
*/
361-
createSnapshot: async function (app, project, user, options) {
354+
async buildSnapshot (app, project, user, options) {
362355
const projectExport = await app.db.controllers.Project.exportProject(project)
363356

364357
const credentialSecret = await project.getCredentialSecret()
@@ -399,6 +392,19 @@ module.exports = {
399392
}, { })
400393
}
401394
}
395+
396+
return snapshotOptions
397+
},
398+
/**
399+
* Creates a snapshot of the current state of a project.
400+
* Patches with flows, credentials, settings modules and env from request, if provided
401+
*
402+
* @param {*} app
403+
* @param {*} project
404+
*/
405+
createSnapshot: async function (app, project, user, options) {
406+
const snapshotOptions = await module.exports.buildSnapshot(app, project, user, options)
407+
402408
const snapshot = await app.db.models.ProjectSnapshot.create(snapshotOptions)
403409
await snapshot.save()
404410
return snapshot

forge/db/controllers/index.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ const modelTypes = [
3030
'StorageFlows',
3131
'StorageSettings',
3232
'StorageSession',
33-
'TeamBrokerClient'
33+
'TeamBrokerClient',
34+
'Assistant'
3435
]
3536

3637
async function init (app) {

forge/db/models/Project.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -378,8 +378,15 @@ module.exports = {
378378
return result
379379
},
380380

381-
async getLatestSnapshot () {
381+
async getLatestSnapshot (excludeAutoGenerated = false) {
382382
const snapshots = await this.getProjectSnapshots({
383+
where: excludeAutoGenerated
384+
? {
385+
name: {
386+
[Op.notLike]: 'Auto Snapshot - %'
387+
}
388+
}
389+
: {},
383390
order: [['createdAt', 'DESC']],
384391
limit: 1
385392
})

forge/ee/lib/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,7 @@ module.exports = fp(async function (app, opts) {
5050

5151
// Set the Editor Limits Feature Flag
5252
app.config.features.register('editorLimits', true, true)
53+
54+
// Set the Editor Limits Feature Flag
55+
app.config.features.register('generatedSnapshotDescription', true, true)
5356
}, { name: 'app.ee.lib' })

forge/lib/objectHelpers.js

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
module.exports = {
2+
/**
3+
* Computes the deep difference between two objects. The result represents
4+
* the changes between `currentState` and `previousState`, identifying additions,
5+
* removals, and modifications in nested structures such as objects and arrays.
6+
*
7+
* @param {Object|Array|null} currentState - The current state object or array to compare.
8+
* @param {Object|Array|null} previousState - The previous state object or array to compare.
9+
* @returns {Object} An object containing two properties:
10+
* - `currentStateDiff`: The differences from the current state, highlighting added or modified values.
11+
* - `previousStateDiff`: The differences from the previous state, highlighting removed or modified values.
12+
*/
13+
deepDiff: (currentState, previousState) => {
14+
const isMergeable = v =>
15+
v !== null &&
16+
typeof v === 'object' &&
17+
(v.constructor === Object || Array.isArray(v))
18+
19+
const sameMergeableType = (a, b) =>
20+
isMergeable(a) && isMergeable(b) &&
21+
(Array.isArray(a) === Array.isArray(b))
22+
23+
function diffNode (a, b) {
24+
if (Object.is(a, b)) return [undefined, undefined]
25+
26+
if (sameMergeableType(a, b)) {
27+
const aKeys = a ? Object.keys(a) : []
28+
const bKeys = b ? Object.keys(b) : []
29+
const keys = new Set([...aKeys, ...bKeys])
30+
31+
const cOut = Array.isArray(a) ? [] : {}
32+
const pOut = Array.isArray(b) ? [] : {}
33+
34+
let touched = false
35+
36+
for (const k of keys) {
37+
const aHas = a != null && Object.prototype.hasOwnProperty.call(a, k)
38+
const bHas = b != null && Object.prototype.hasOwnProperty.call(b, k)
39+
40+
const aVal = aHas ? a[k] : undefined
41+
const bVal = bHas ? b[k] : undefined
42+
43+
// addition -> only record on current side
44+
if (aHas && !bHas) {
45+
cOut[k] = cloneShallow(aVal)
46+
touched = true
47+
continue
48+
}
49+
// removal -> only record on previous side
50+
if (!aHas && bHas) {
51+
pOut[k] = cloneShallow(bVal)
52+
touched = true
53+
continue
54+
}
55+
56+
// both present
57+
const [cChild, pChild] = diffNode(aVal, bVal)
58+
if (cChild !== undefined) {
59+
cOut[k] = cChild
60+
touched = true
61+
}
62+
if (pChild !== undefined) {
63+
pOut[k] = pChild
64+
touched = true
65+
}
66+
}
67+
68+
if (!touched) return [undefined, undefined]
69+
return [isEmpty(cOut) ? undefined : cOut, isEmpty(pOut) ? undefined : pOut]
70+
}
71+
72+
// value changed (present in both)
73+
return [cloneShallow(a), cloneShallow(b)]
74+
}
75+
76+
function isEmpty (x) {
77+
return x && typeof x === 'object' && Object.keys(x).length === 0
78+
}
79+
80+
function cloneShallow (v) {
81+
if (Array.isArray(v)) return v.slice()
82+
if (v && v.constructor === Object) return { ...v }
83+
return v
84+
}
85+
86+
const [c, p] = diffNode(currentState, previousState)
87+
return {
88+
currentStateDiff: c || {},
89+
previousStateDiff: p || {}
90+
}
91+
}
92+
}

forge/routes/api/assistant.js

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -173,8 +173,6 @@ module.exports = async function (app) {
173173
return reply.code(400).send({ code: 'invalid_method', error: 'Invalid method name' })
174174
}
175175

176-
const url = `${serviceUrl.replace(/\/+$/, '')}/${method.replace(/^\/+/, '')}`
177-
178176
// if this is a `flowfuse-tables-query` lets see if tables are enabled and try to get the schema hints
179177
const tablesFeatureEnabled = app.config.features.enabled('tables') && request.team.TeamType.getFeatureProperty('tables', false)
180178
const isTablesQuery = tablesFeatureEnabled && method === 'flowfuse-tables-query'
@@ -198,19 +196,30 @@ module.exports = async function (app) {
198196

199197
// post to the assistant service
200198
try {
201-
const headers = await buildRequestHeaders(request)
199+
let isTeamOnTrial
200+
if (app.billing && request.team.getSubscription) {
201+
const subscription = await request.team.getSubscription()
202+
isTeamOnTrial = subscription ? subscription.isTrial() : null
203+
}
202204
const data = { ...request.body }
203205
if (isTablesQuery) {
204206
data.context = data.context || {}
205207
data.context.tablesSchema = tablesSchemaCache.get(tablesCacheKey)
206208
}
207-
const response = await axios.post(url, data, {
208-
headers,
209-
timeout: requestTimeout
210-
})
211-
if (request.body.transactionId !== response.data.transactionId) {
209+
210+
const response = await app.db.controllers.Assistant.invokeLLM(
211+
method, data, {
212+
teamHashId: request.team.hashid,
213+
instanceType: request.ownerType,
214+
instanceId: request.ownerId,
215+
additionalHeaders: request.headers,
216+
isTeamOnTrial
217+
})
218+
219+
if (request.body.transactionId !== response.transactionId) {
212220
throw new Error('Transaction ID mismatch') // Ensure we are responding to the correct transaction
213221
}
222+
214223
reply.send(response.data)
215224
} catch (error) {
216225
reply.code(error.response?.status || 500).send({ code: error.response?.data?.code || 'unexpected_error', error: error.response?.data?.error || error.message })

0 commit comments

Comments
 (0)