Skip to content

Commit

Permalink
Merge pull request #4128 from FlowFuse/4126-json-assistant
Browse files Browse the repository at this point in the history
Add support for JSON in FlowFuse Assistant
  • Loading branch information
Steve-Mcl authored Jul 11, 2024
2 parents 58c386a + 6cf59a4 commit ec4247a
Show file tree
Hide file tree
Showing 5 changed files with 105 additions and 57 deletions.
7 changes: 7 additions & 0 deletions docs/user/assistant.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,10 @@ to generate code directly within the editor.
This is useful when you want to quickly add code to an
existing function node without having to generate a full function node from scratch.

### In-line JSON Generation

The FlowFuse Assistant plugin also adds a new code lens to the JSON editor that allows you
to generate JSON directly within the monaco editor.

This is useful when you want to quickly generate JSON in a template node, change node, inject node or
any node that the TypedInput offers the JSON editor.
2 changes: 1 addition & 1 deletion forge/lib/permissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ const Permissions = {
'platform:audit-log': { description: 'View platform audit log', role: Roles.Admin },

// assistant
'assistant:function': { description: 'Access the assistant function endpoint', role: Roles.Member }
'assistant:method': { description: 'Access the assistant method endpoint', role: Roles.Member }
}

module.exports = {
Expand Down
12 changes: 7 additions & 5 deletions forge/routes/api/assistant.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ module.exports = async function (app) {
app.addHook('preHandler', app.verifySession)

/**
* Endpoint for assistant functions
* Endpoint for assistant methods
* For now, this is simply a relay to an external assistant service
* In the future, we may decide to bring that service inside the core or
* use an alternative means of accessing it.
*/
app.post('/function', {
preHandler: app.needsPermission('assistant:function'),
app.post('/:method', {
preHandler: app.needsPermission('assistant:method'),
schema: {
hide: true, // dont show in swagger
body: {
Expand All @@ -45,8 +45,10 @@ module.exports = async function (app) {
}
},
async (request, reply) => {
// const method = request.params.method // FUTURE: allow for different methods
const method = 'function' // for now, only function node/code generation is supported
const method = request.params.method // the method to call at the assistant service
if (/^[a-z0-9_-]+$/.test(method) === false) {
return reply.code(400).send({ code: 'invalid_method', error: 'Invalid method name' })
}

const serviceUrl = app.config.assistant?.service?.url
const serviceToken = app.config.assistant?.service?.token
Expand Down
4 changes: 2 additions & 2 deletions forge/routes/auth/permissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const IMPLICIT_TOKEN_SCOPES = {
'team:projects:list', // permit a device being edited via a tunnel in developer mode to list projects
'library:entry:create', // permit a device being edited via a tunnel in developer mode to create library entries
'library:entry:list', // permit a device being edited via a tunnel in developer mode to list library entries
'assistant:function' // permit calls to the assistant endpoint for function node/code creation
'assistant:method' // permit calls to the assistant endpoint for method node/code/json/etc creation
],
project: [
'user:read',
Expand All @@ -19,7 +19,7 @@ const IMPLICIT_TOKEN_SCOPES = {
'team:projects:list',
'library:entry:create',
'library:entry:list',
'assistant:function'
'assistant:method'
]
}

Expand Down
137 changes: 88 additions & 49 deletions test/unit/forge/routes/api/assistant_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,73 +92,112 @@ describe('Assistant API', async function () {
response.statusCode.should.equal(501)
})
})
describe('function service', async function () {
it('anonymous cannot access /function', async function () {
const response = await app.inject({
method: 'GET',
url: '/api/v1/assistant/function'
})
response.statusCode.should.equal(401)
})
it('random token cannot access /function', async function () {
const response = await app.inject({
method: 'GET',
url: '/api/v1/assistant/function',
headers: { authorization: 'Bearer blah-blah' }
})
response.statusCode.should.equal(401)
})
it('device token can access /function', async function () {
// const device = await createDevice({ name: 'Ad1', type: 'Ad1_type', team: TestObjects.ATeam.hashid, as: TestObjects.tokens.alice })
const deviceCreateResponse = await app.inject({
method: 'POST',
url: '/api/v1/devices',
body: {
name: 'Ad1',
type: 'Ad1_type',
team: TestObjects.ATeam.hashid
},
cookies: { sid: TestObjects.tokens.alice }
})
const device = deviceCreateResponse.json()
sinon.stub(axios, 'post').resolves({ data: { status: 'ok' } })

describe('method constraints', async function () {
it('should return 400 if method contains capital letters', async function () {
const response = await app.inject({
method: 'POST',
url: '/api/v1/assistant/function',
headers: { authorization: 'Bearer ' + device.credentials.token },
url: '/api/v1/assistant/InvalidMethod',
headers: { authorization: 'Bearer ' + TestObjects.tokens.instance },
payload: { prompt: 'multiply by 5', transactionId: '1234' }
})
axios.post.calledOnce.should.be.true()
response.statusCode.should.equal(200)
response.statusCode.should.equal(400)
})
it('instance token can access /function', async function () {
sinon.stub(axios, 'post').resolves({ data: { status: 'ok' } })
it('should return 400 if method contains invalid characters', async function () {
const response = await app.inject({
method: 'POST',
url: '/api/v1/assistant/function',
url: '/api/v1/assistant/inv@lid',
headers: { authorization: 'Bearer ' + TestObjects.tokens.instance },
payload: { prompt: 'multiply by 5', transactionId: '1234' }
})
axios.post.calledOnce.should.be.true()
response.statusCode.should.equal(200)
response.statusCode.should.equal(400)
})
it('fails when prompt is not supplied', async function () {
it('should return 400 if method contains escaped characters', async function () {
const response = await app.inject({
method: 'POST',
url: '/api/v1/assistant/function',
url: '/api/v1/assistant/method%2Fwith%2Fslashes',
headers: { authorization: 'Bearer ' + TestObjects.tokens.instance },
payload: { transactionId: '1234' }
payload: { prompt: 'multiply by 5', transactionId: '1234' }
})
response.statusCode.should.equal(400)
})
it('fails when transactionId is not supplied', async function () {
const response = await app.inject({
method: 'POST',
url: '/api/v1/assistant/function',
headers: { authorization: 'Bearer ' + TestObjects.tokens.instance },
payload: { prompt: 'multiply by 5' }
})

describe('service tests', async function () {
function serviceTests (serviceName) {
it('anonymous cannot access', async function () {
const response = await app.inject({
method: 'GET',
url: `/api/v1/assistant/${serviceName}`
})
response.statusCode.should.equal(401)
})
response.statusCode.should.equal(400)
it('random token cannot access', async function () {
const response = await app.inject({
method: 'GET',
url: `/api/v1/assistant/${serviceName}`,
headers: { authorization: 'Bearer blah-blah' }
})
response.statusCode.should.equal(401)
})
it('device token can access', async function () {
// const device = await createDevice({ name: 'Ad1', type: 'Ad1_type', team: TestObjects.ATeam.hashid, as: TestObjects.tokens.alice })
const deviceCreateResponse = await app.inject({
method: 'POST',
url: '/api/v1/devices',
body: {
name: 'Ad1',
type: 'Ad1_type',
team: TestObjects.ATeam.hashid
},
cookies: { sid: TestObjects.tokens.alice }
})
const device = deviceCreateResponse.json()
sinon.stub(axios, 'post').resolves({ data: { status: 'ok' } })
const response = await app.inject({
method: 'POST',
url: `/api/v1/assistant/${serviceName}`,
headers: { authorization: 'Bearer ' + device.credentials.token },
payload: { prompt: 'multiply by 5', transactionId: '1234' }
})
axios.post.calledOnce.should.be.true()
response.statusCode.should.equal(200)
})
it('instance token can access', async function () {
sinon.stub(axios, 'post').resolves({ data: { status: 'ok' } })
const response = await app.inject({
method: 'POST',
url: `/api/v1/assistant/${serviceName}`,
headers: { authorization: 'Bearer ' + TestObjects.tokens.instance },
payload: { prompt: 'multiply by 5', transactionId: '1234' }
})
axios.post.calledOnce.should.be.true()
response.statusCode.should.equal(200)
})
it('fails when prompt is not supplied', async function () {
const response = await app.inject({
method: 'POST',
url: `/api/v1/assistant/${serviceName}`,
headers: { authorization: 'Bearer ' + TestObjects.tokens.instance },
payload: { transactionId: '1234' }
})
response.statusCode.should.equal(400)
})
it('fails when transactionId is not supplied', async function () {
const response = await app.inject({
method: 'POST',
url: `/api/v1/assistant/${serviceName}`,
headers: { authorization: 'Bearer ' + TestObjects.tokens.instance },
payload: { prompt: 'multiply by 5' }
})
response.statusCode.should.equal(400)
})
}
describe('function service', async function () {
serviceTests('function')
})
describe('json service', async function () {
serviceTests('json')
})
})
})

0 comments on commit ec4247a

Please sign in to comment.