mirror of
https://github.com/OpenBankProject/API-Explorer-II.git
synced 2026-02-06 10:47:04 +00:00
Merge pull request #83 from nemozak1/opey-II-integration
Opey ii integration
This commit is contained in:
commit
6aca5b8b83
6
babel.config.js
Normal file
6
babel.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
['@babel/preset-env', {targets: {node: 'current'}}],
|
||||
'@babel/preset-typescript',
|
||||
],
|
||||
};
|
||||
1
components.d.ts
vendored
1
components.d.ts
vendored
@ -8,6 +8,7 @@ export {}
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ChatWidget: typeof import('./src/components/ChatWidget.vue')['default']
|
||||
ChatWidgetII: typeof import('./src/components/ChatWidgetII.vue')['default']
|
||||
Collections: typeof import('./src/components/Collections.vue')['default']
|
||||
Content: typeof import('./src/components/Content.vue')['default']
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
|
||||
11
jest.config.js
Normal file
11
jest.config.js
Normal file
@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
transform: {
|
||||
'^.+\\.ts?$': 'ts-jest',
|
||||
"^.+\\.(js)$": "babel-jest",
|
||||
},
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
testRegex: '/tests/.*\\.(test|spec)?\\.(ts|tsx)$',
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
|
||||
detectOpenHandles: true,
|
||||
};
|
||||
21
package.json
21
package.json
@ -2,12 +2,17 @@
|
||||
"name": "api-explorer",
|
||||
"version": "1.1.3",
|
||||
"private": true,
|
||||
"types": [
|
||||
"jest",
|
||||
"node"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "vite & ts-node server/app.ts",
|
||||
"build": "run-p build-only",
|
||||
"build-server": "tsc --project tsconfig.server.json",
|
||||
"preview": "vite preview",
|
||||
"test:unit": "vitest",
|
||||
"test": "jest --silent=false",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
||||
@ -17,6 +22,9 @@
|
||||
"@element-plus/icons-vue": "^2.1.0",
|
||||
"@fontsource/roboto": "^5.0.0",
|
||||
"@highlightjs/vue-plugin": "^2.1.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"ai": "^4.1.11",
|
||||
"axios": "^1.7.4",
|
||||
"cheerio": "^1.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
@ -26,9 +34,12 @@
|
||||
"element-plus": "^2.3.9",
|
||||
"express": "^4.21.0",
|
||||
"express-session": "^1.17.3",
|
||||
"got": "^14.4.5",
|
||||
"highlight.js": "^11.8.0",
|
||||
"json-editor-vue": "^0.17.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"markdown-it": "^14.1.0",
|
||||
"node-fetch": "v2.6",
|
||||
"oauth": "^0.10.0",
|
||||
"obp-typescript": "^1.0.36",
|
||||
"pinia": "^2.0.37",
|
||||
@ -38,8 +49,10 @@
|
||||
"routing-controllers": "^0.10.4",
|
||||
"socket.io": "^4.7.5",
|
||||
"socket.io-client": "^4.7.5",
|
||||
"supertest": "^7.0.0",
|
||||
"typedi": "^0.10.0",
|
||||
"uuid": "^9.0.1",
|
||||
"vanilla-jsoneditor": "^2.3.3",
|
||||
"vue": "^3.5.1",
|
||||
"vue-i18n": "^9.4.0",
|
||||
"vue-router": "^4.2.2",
|
||||
@ -47,23 +60,29 @@
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.26.8",
|
||||
"@babel/preset-env": "^7.26.8",
|
||||
"@babel/preset-typescript": "^7.26.0",
|
||||
"@rushstack/eslint-patch": "^1.4.0",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/markdown-it": "^14.1.1",
|
||||
"@types/node": "^20.5.7",
|
||||
"@types/node": "^20.17.17",
|
||||
"@vitejs/plugin-vue": "^4.3.0",
|
||||
"@vitejs/plugin-vue-jsx": "^3.1.0",
|
||||
"@vue/eslint-config-prettier": "^9.0.0",
|
||||
"@vue/eslint-config-typescript": "^14.0.0",
|
||||
"@vue/test-utils": "^2.4.0",
|
||||
"@vue/tsconfig": "^0.1.3",
|
||||
"babel-jest": "^29.7.0",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-plugin-vue": "^9.12.0",
|
||||
"jest": "^29.7.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"npm-run-all2": "^7.0.1",
|
||||
"prettier": "^3.0.1",
|
||||
"superagent": "^9.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "~5.2.2",
|
||||
"unplugin-auto-import": "^0.18.0",
|
||||
|
||||
@ -138,9 +138,11 @@ console.log('Execution continues with commitId:', commitId);
|
||||
|
||||
// Error Handling to Shut Down the App
|
||||
server.on('error', (err) => {
|
||||
redisClient.disconnect();
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
console.error(`Port ${port} is already in use.`);
|
||||
process.exit(1); // Shut down the app
|
||||
process.exit(1);
|
||||
// Shut down the app
|
||||
} else {
|
||||
console.error('An error occurred:', err);
|
||||
}
|
||||
|
||||
@ -33,7 +33,7 @@ import * as fs from 'fs'
|
||||
import * as jwt from 'jsonwebtoken'
|
||||
|
||||
@Service()
|
||||
@Controller('/opey')
|
||||
@Controller('/opey-old')
|
||||
/**
|
||||
* Controller class for handling Opey related operations.
|
||||
* This used to hold the /chat endpoint, but that endpoint has become obsolete since using websockets.
|
||||
@ -45,6 +45,94 @@ export class OpeyController {
|
||||
private obpClientService: OBPClientService,
|
||||
) {}
|
||||
|
||||
@Post('/consent')
|
||||
/**
|
||||
* Retrieves a consent from OBP for the current user
|
||||
*/
|
||||
async getConsent(
|
||||
@Session() session: any,
|
||||
@Req() request: Request,
|
||||
@Res() response: Response
|
||||
): Response {
|
||||
try {
|
||||
console.log("Getting consent from OBP")
|
||||
// Check if consent is already in session
|
||||
if (session['obpConsent']) {
|
||||
console.log("Consent found in session, returning cached consent ID")
|
||||
const obpConsent = session['obpConsent']
|
||||
// NOTE: Arguably we should not return the consent to the frontend as it could be hijacked,
|
||||
// we can keep everything in the backend and only return the JWT token
|
||||
return response.status(200).json({consent_id: obpConsent.consent_id});
|
||||
}
|
||||
|
||||
const oauthConfig = session['clientConfig']
|
||||
const version = this.obpClientService.getOBPVersion()
|
||||
// Obbiously this should not be hard-coded, especially the consumer_id, but for now it is
|
||||
const consentRequestBody = {
|
||||
"everything": false,
|
||||
"views": [],
|
||||
"entitlements": [],
|
||||
"consumer_id": "33e0a1bd-9f1d-4128-911b-8936110f802f"
|
||||
}
|
||||
|
||||
// Get current user, only proceed if user is logged in
|
||||
const currentUser = await this.obpClientService.get(`/obp/${version}/users/current`, oauthConfig)
|
||||
const currentResponseKeys = Object.keys(currentUser)
|
||||
if (!currentResponseKeys.includes('user_id')) {
|
||||
return response.status(400).json({ message: 'User not logged in, Authentication required' });
|
||||
}
|
||||
|
||||
// url needs to be changed once we get the 'bankless' consent endpoint
|
||||
// this creates a consent for the current logged in user, and starts SCA flow i.e. sends SMS or email OTP to user
|
||||
const consent = await this.obpClientService.create(`/obp/${version}/banks/gh.29.uk/my/consents/IMPLICIT`, consentRequestBody, oauthConfig)
|
||||
console.log("Consent: ", consent)
|
||||
|
||||
// store consent in session, return consent 200 OK
|
||||
session['obpConsent'] = consent
|
||||
return response.status(200).json({consent_id: consent.consent_id});
|
||||
} catch (error) {
|
||||
console.error("Error in consent endpoint: ", error);
|
||||
return response.status(500).json({ error: 'Internal Server Error '});
|
||||
}
|
||||
}
|
||||
|
||||
@Post('/consent/answer-challenge')
|
||||
/**
|
||||
* Endpoint to answer the consent challenge with code i.e. SMS or email OTP for SCA
|
||||
* If successful, returns a Consent-JWT for use by Opey to access endpoints/ roles that the consenting user has
|
||||
* This completes (i.e. is the final step in) the consent flow
|
||||
*/
|
||||
async answerConsentChallenge(
|
||||
@Session() session: any,
|
||||
@Req() request: Request,
|
||||
@Res() response: Response
|
||||
): Response {
|
||||
try {
|
||||
const oauthConfig = session['clientConfig']
|
||||
const version = this.obpClientService.getOBPVersion()
|
||||
|
||||
const obpConsent = session['obpConsent']
|
||||
if (!obpConsent) {
|
||||
return response.status(400).json({ message: 'Consent not found in session' });
|
||||
} else if (obpConsent.status === 'ACCEPTED') {
|
||||
return response.status(400).json({ message: 'Consent already accepted' });
|
||||
}
|
||||
const answerBody = request.body
|
||||
|
||||
const consentJWT = await this.obpClientService.create(`/obp/${version}/banks/gh.29.uk/consents/${obpConsent.consent_id}/challenge`, answerBody, oauthConfig)
|
||||
console.log("Consent JWT: ", consentJWT)
|
||||
// store consent JWT in session, return consent JWT 200 OK
|
||||
session['obpConsentJWT'] = consentJWT
|
||||
return response.status(200).json(true);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error in consent/answer-challenge endpoint: ", error);
|
||||
return response.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@Post('/token')
|
||||
/**
|
||||
* Retrieves a JWT token for the current user.
|
||||
|
||||
220
server/controllers/OpeyIIController.ts
Normal file
220
server/controllers/OpeyIIController.ts
Normal file
@ -0,0 +1,220 @@
|
||||
import { streamText } from 'ai'
|
||||
import axios from 'axios'
|
||||
import { Controller, Session, Req, Res, Post, Get } from 'routing-controllers'
|
||||
import { Request, Response } from 'express'
|
||||
import { Service } from 'typedi'
|
||||
import OBPClientService from '../services/OBPClientService'
|
||||
import OpeyClientService from '../services/OpeyClientService'
|
||||
import { v6 as uuid6 } from 'uuid';
|
||||
import { Transform } from 'stream'
|
||||
import { UserInput } from '../schema/OpeySchema'
|
||||
|
||||
@Service()
|
||||
@Controller('/opey')
|
||||
|
||||
export class OpeyController {
|
||||
constructor(
|
||||
private obpClientService: OBPClientService,
|
||||
private opeyClientService: OpeyClientService,
|
||||
) {}
|
||||
|
||||
@Get('/')
|
||||
async getStatus(
|
||||
@Res() response: Response
|
||||
): Response {
|
||||
|
||||
try {
|
||||
const opeyStatus = await this.opeyClientService.getOpeyStatus()
|
||||
console.log("Opey status: ", opeyStatus)
|
||||
return response.status(200).json({status: 'Opey is running'});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error in /opey endpoint: ", error);
|
||||
return response.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@Post('/stream')
|
||||
|
||||
async streamOpey(
|
||||
@Session() session: any,
|
||||
@Req() request: Request,
|
||||
@Res() response: Response
|
||||
) {
|
||||
|
||||
let user_input: UserInput
|
||||
try {
|
||||
user_input = {
|
||||
"message": request.body.message,
|
||||
"thread_id": request.body.thread_id,
|
||||
"is_tool_call_approval": request.body.is_tool_call_approval
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in stream endpoint, could not parse into UserInput: ", error)
|
||||
return response.status(500).json({ error: 'Internal Server Error' })
|
||||
}
|
||||
|
||||
|
||||
console.log("Calling OpeyClientService.stream")
|
||||
|
||||
const streamMiddlewareTransform = new Transform({
|
||||
transform(chunk, encoding, callback) {
|
||||
console.log(`Logged Chunk: ${chunk}`)
|
||||
this.push(chunk);
|
||||
|
||||
callback();
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const nodeStream = await this.opeyClientService.stream(user_input)
|
||||
console.log(`Stream received from OpeyClientService.stream: ${nodeStream.readable}`)
|
||||
nodeStream.pipe(streamMiddlewareTransform).pipe(response)
|
||||
|
||||
response.status(200)
|
||||
response.setHeader('Content-Type', 'text/event-stream')
|
||||
response.setHeader('Cache-Control', 'no-cache')
|
||||
response.setHeader('Connection', 'keep-alive')
|
||||
|
||||
// nodeStream.on('data', (chunk) => {
|
||||
// const data = chunk.toString()
|
||||
// console.log(`data: ${data}`)
|
||||
// response.write(`data: ${data}\n\n`)
|
||||
// })
|
||||
// nodeStream.on('end', () => {
|
||||
// console.log('Stream ended')
|
||||
// response.end()
|
||||
// })
|
||||
// nodeStream.on('error', (error) => {
|
||||
// console.error(error)
|
||||
// response.write(`data: Error reading stream\n\n`)
|
||||
// response.end()
|
||||
// })
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
response.status(500).json({ error: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
@Post('/invoke')
|
||||
async invokeOpey(
|
||||
@Session() session: any,
|
||||
@Req() request: Request,
|
||||
@Res() response: Response
|
||||
): Response {
|
||||
|
||||
let user_input: UserInput
|
||||
try {
|
||||
user_input = {
|
||||
"message": request.body.message,
|
||||
"thread_id": request.body.thread_id,
|
||||
"is_tool_call_approval": request.body.is_tool_call_approval
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in stream endpoint, could not parse into UserInput: ", error)
|
||||
return response.status(500).json({ error: 'Internal Server Error' })
|
||||
}
|
||||
|
||||
try {
|
||||
const opey_response = await this.opeyClientService.invoke(user_input)
|
||||
|
||||
console.log("Opey response: ", opey_response)
|
||||
return response.status(200).json(opey_response)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return response.status(500).json({ error: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
@Post('/consent')
|
||||
/**
|
||||
* Retrieves a consent from OBP for the current user
|
||||
*/
|
||||
async getConsent(
|
||||
@Session() session: any,
|
||||
@Req() request: Request,
|
||||
@Res() response: Response
|
||||
): Response {
|
||||
try {
|
||||
console.log("Getting consent from OBP")
|
||||
// Check if consent is already in session
|
||||
if (session['obpConsent']) {
|
||||
console.log("Consent found in session, returning cached consent ID")
|
||||
const obpConsent = session['obpConsent']
|
||||
// NOTE: Arguably we should not return the consent to the frontend as it could be hijacked,
|
||||
// we can keep everything in the backend and only return the JWT token
|
||||
return response.status(200).json({consent_id: obpConsent.consent_id});
|
||||
}
|
||||
|
||||
const oauthConfig = session['clientConfig']
|
||||
const version = this.obpClientService.getOBPVersion()
|
||||
// Obbiously this should not be hard-coded, especially the consumer_id, but for now it is
|
||||
const consentRequestBody = {
|
||||
"everything": false,
|
||||
"views": [],
|
||||
"entitlements": [],
|
||||
"consumer_id": "33e0a1bd-9f1d-4128-911b-8936110f802f"
|
||||
}
|
||||
|
||||
// Get current user, only proceed if user is logged in
|
||||
const currentUser = await this.obpClientService.get(`/obp/${version}/users/current`, oauthConfig)
|
||||
const currentResponseKeys = Object.keys(currentUser)
|
||||
if (!currentResponseKeys.includes('user_id')) {
|
||||
return response.status(400).json({ message: 'User not logged in, Authentication required' });
|
||||
}
|
||||
|
||||
// url needs to be changed once we get the 'bankless' consent endpoint
|
||||
// this creates a consent for the current logged in user, and starts SCA flow i.e. sends SMS or email OTP to user
|
||||
const consent = await this.obpClientService.create(`/obp/${version}/banks/gh.29.uk/my/consents/IMPLICIT`, consentRequestBody, oauthConfig)
|
||||
console.log("Consent: ", consent)
|
||||
|
||||
// store consent in session, return consent 200 OK
|
||||
session['obpConsent'] = consent
|
||||
return response.status(200).json({consent_id: consent.consent_id});
|
||||
} catch (error) {
|
||||
console.error("Error in consent endpoint: ", error);
|
||||
return response.status(500).json({ error: 'Internal Server Error '});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Post('/consent/answer-challenge')
|
||||
/**
|
||||
* Endpoint to answer the consent challenge with code i.e. SMS or email OTP for SCA
|
||||
* If successful, returns a Consent-JWT for use by Opey to access endpoints/ roles that the consenting user has
|
||||
* This completes (i.e. is the final step in) the consent flow
|
||||
*/
|
||||
async answerConsentChallenge(
|
||||
@Session() session: any,
|
||||
@Req() request: Request,
|
||||
@Res() response: Response
|
||||
): Response {
|
||||
try {
|
||||
const oauthConfig = session['clientConfig']
|
||||
const version = this.obpClientService.getOBPVersion()
|
||||
|
||||
const obpConsent = session['obpConsent']
|
||||
if (!obpConsent) {
|
||||
return response.status(400).json({ message: 'Consent not found in session' });
|
||||
} else if (obpConsent.status === 'ACCEPTED') {
|
||||
return response.status(400).json({ message: 'Consent already accepted' });
|
||||
}
|
||||
const answerBody = request.body
|
||||
|
||||
const consentJWT = await this.obpClientService.create(`/obp/${version}/banks/gh.29.uk/consents/${obpConsent.consent_id}/challenge`, answerBody, oauthConfig)
|
||||
console.log("Consent JWT: ", consentJWT)
|
||||
// store consent JWT in session, return consent JWT 200 OK
|
||||
session['obpConsentJWT'] = consentJWT
|
||||
return response.status(200).json(true);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error in consent/answer-challenge endpoint: ", error);
|
||||
return response.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
25
server/schema/OpeySchema.ts
Normal file
25
server/schema/OpeySchema.ts
Normal file
@ -0,0 +1,25 @@
|
||||
|
||||
export class UserInput {
|
||||
message: string;
|
||||
thread_id?: string | null;
|
||||
is_tool_call_approval: boolean;
|
||||
}
|
||||
|
||||
export class StreamInput extends UserInput {
|
||||
stream_tokens: boolean;
|
||||
}
|
||||
|
||||
export type OpeyPaths = {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export type OpeyConfig = {
|
||||
baseUri: string,
|
||||
authConfig: any,
|
||||
paths: OpeyPaths,
|
||||
}
|
||||
|
||||
export type AuthConfig = {
|
||||
consentId: string,
|
||||
opeyJWT: string,
|
||||
}
|
||||
105
server/services/OpeyClientService.ts
Normal file
105
server/services/OpeyClientService.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { Service } from 'typedi'
|
||||
import { UserInput, StreamInput, OpeyConfig, AuthConfig } from '../schema/OpeySchema'
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
@Service()
|
||||
export default class OpeyClientService {
|
||||
private authConfig: AuthConfig
|
||||
private opeyConfig: OpeyConfig
|
||||
constructor() {
|
||||
this.authConfig = {
|
||||
consentId: '',
|
||||
opeyJWT: ''
|
||||
}
|
||||
this.opeyConfig = {
|
||||
baseUri: process.env.VITE_CHATBOT_URL? process.env.VITE_CHATBOT_URL : 'http://localhost:5000',
|
||||
authConfig: this.authConfig,
|
||||
paths: {
|
||||
status: '/status',
|
||||
stream: '/stream',
|
||||
invoke: '/invoke',
|
||||
approve_tool: '/approve_tool/{thead_id}',
|
||||
feedback: '/feedback',
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
async getOpeyStatus(): Promise<any> {
|
||||
// Endpoint to check if Opey is running
|
||||
try {
|
||||
const url = `${this.opeyConfig.baseUri}${this.opeyConfig.paths.status}`
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {}
|
||||
})
|
||||
if (response.status === 200) {
|
||||
const status = await response.json()
|
||||
return status
|
||||
} else {
|
||||
throw new Error(`Error getting status from Opey: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`Error getting status from Opey: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
async stream(user_input: UserInput): Promise<NodeJS.ReadableStream> {
|
||||
// Endpoint to post a message to Opey and stream the response tokens/messages
|
||||
try {
|
||||
|
||||
const url = `${this.opeyConfig.baseUri}${this.opeyConfig.paths.stream}`
|
||||
// We need to set whether we want to stream tokens or not
|
||||
const stream_input = user_input as StreamInput
|
||||
stream_input.stream_tokens = true
|
||||
|
||||
console.log(`Posting to Opey with streaming: ${JSON.stringify(stream_input)}\n URL: ${url}`) //DEBUG
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
"Authorization": `Bearer ${this.opeyConfig.authConfig.opeyJWT}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(stream_input)
|
||||
})
|
||||
if (!response.body) {
|
||||
throw new Error("No response body")
|
||||
}
|
||||
return response.body as NodeJS.ReadableStream
|
||||
}
|
||||
catch (error) {
|
||||
throw new Error(`Error streaming from Opey: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
async invoke(user_input: UserInput): Promise<any> {
|
||||
// Endpoint to post a message to Opey and get a response without stream
|
||||
// I.e. a normal REST call
|
||||
const url = `${this.opeyConfig.baseUri}${this.opeyConfig.paths.invoke}`
|
||||
|
||||
console.log(`Posting to Opey, STREAMING OFF: ${JSON.stringify(user_input)}\n URL: ${url}`) //DEBUG
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
"Authorization": `Bearer ${this.opeyConfig.authConfig.opeyJWT}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(user_input)
|
||||
})
|
||||
if (response.status === 200) {
|
||||
const opey_response = await response.json()
|
||||
return opey_response
|
||||
} else {
|
||||
throw new Error(`Error invoking Opey: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Error invoking Opey: ${error}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -28,12 +28,13 @@
|
||||
<script>
|
||||
import Prism from 'prismjs';
|
||||
import MarkdownIt from "markdown-it";
|
||||
import axios from 'axios';
|
||||
import 'prismjs/themes/prism.css'; // Choose a theme you like
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { inject } from 'vue';
|
||||
import { obpApiHostKey } from '@/obp/keys';
|
||||
import { getCurrentUser } from '../obp';
|
||||
import { getOpeyJWT } from '@/obp/common-functions'
|
||||
import { getOpeyJWT, getOpeyConsent, answerOpeyConsentChallenge } from '@/obp/common-functions'
|
||||
import { storeToRefs } from "pinia";
|
||||
import { socket } from '@/socket';
|
||||
import { useConnectionStore } from '@/stores/connection';
|
||||
@ -72,14 +73,18 @@
|
||||
|
||||
const { isConnected } = storeToRefs(connectionStore);
|
||||
|
||||
return {isStreaming, chatMessages, lastError, currentMessageSnapshot, chatStore, connectionStore, isConnected}
|
||||
return {isStreaming, chatMessages, lastError, currentMessageSnapshot, chatStore, connectionStore}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isOpen: false,
|
||||
userInput: '',
|
||||
sessionId: uuidv4(),
|
||||
isConnected: false,
|
||||
awaitingConnection: !this.isConnected,
|
||||
awaitingConsentChallengeAnswer: false,
|
||||
consentChallengeAnswer: '',
|
||||
consentId: '',
|
||||
isLoading: false,
|
||||
obpApiHost: null,
|
||||
isLoggedIn: null,
|
||||
@ -116,33 +121,82 @@
|
||||
},
|
||||
async establishWebSocketConnection() {
|
||||
// Get the Opey JWT token
|
||||
let token = ''
|
||||
// try to get a consent token
|
||||
|
||||
// Check if the user already has a token in the cookies
|
||||
|
||||
try {
|
||||
token = await getOpeyJWT()
|
||||
const consentResponse = await getOpeyConsent()
|
||||
console.log('Consent response: ', consentResponse)
|
||||
if (consentResponse.status === 200 && consentResponse.data.consent_id) {
|
||||
this.consentId = consentResponse.data.consent_id
|
||||
this.awaitingConsentChallengeAnswer = true
|
||||
} else {
|
||||
console.log('Error getting consent for opey from OBP: ', consentResponse)
|
||||
this.errorState = true
|
||||
ElMessage({
|
||||
message: 'Error getting consent for opey from OBP',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.log('Error creating JWT for opey: ', error)
|
||||
console.log('Error getting consent for opey from OBP: ', error)
|
||||
this.errorState = true
|
||||
ElMessage({
|
||||
message: 'Error getting Opey JWT token',
|
||||
message: 'Error getting consent for opey from OBP',
|
||||
type: 'error'
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
// Establish the WebSocket connection
|
||||
console.log('Establishing WebSocket connection');
|
||||
try{
|
||||
this.connectionStore.connect(token)
|
||||
} catch (error) {
|
||||
console.log('Error establishing WebSocket connection: ', error)
|
||||
this.errorState = true
|
||||
ElMessage({
|
||||
message: 'Error establishing WebSocket connection',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
|
||||
},
|
||||
async answerConsentChallenge() {
|
||||
const challengeAnswer = this.consentChallengeAnswer
|
||||
if (!challengeAnswer) {
|
||||
console.error("empty challenge answer")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Answering consent challenge with: ${challengeAnswer} and consent_id: ${this.consentId}`)
|
||||
|
||||
|
||||
// send the challenge answer to Opey for approval
|
||||
const response = await axios.post(
|
||||
`${this.chatBotUrl}/auth`,
|
||||
JSON.stringify({"consent_id": this.consentId, "consent_challenge_answer": challengeAnswer}),
|
||||
{
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
withCredentials: true,
|
||||
}
|
||||
)
|
||||
|
||||
console.log("Consent challenge response: ", response.status, response.headers)
|
||||
if (response.status === 200) {
|
||||
console.log('Consent challenge answered successfully, Consent approved')
|
||||
this.awaitingConsentChallengeAnswer = false
|
||||
if (response.data.success) {
|
||||
console.log('Consent approved')
|
||||
this.isConnected = true
|
||||
} else {
|
||||
console.log('Consent denied')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
console.log('Error answering consent challenge: ', error)
|
||||
this.errorState = true
|
||||
ElMessage({
|
||||
message: 'Error answering consent challenge',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async sendMessage() {
|
||||
if (this.userInput.trim()) {
|
||||
// Message in OpenAI standard format for user message
|
||||
@ -269,7 +323,15 @@
|
||||
<span>Chat with Opey</span>
|
||||
<img alt="Powered by OpenAI" src="@/assets/powered-by-openai-badge-outlined-on-dark.svg" height="32">
|
||||
</div>
|
||||
<div v-if="this.isLoggedIn" v-loading="this.awaitingConnection" element-loading-text="Awaiting Connection..." class="chat-messages" ref="messages">
|
||||
<div v-show="this.awaitingConsentChallengeAnswer">
|
||||
<el-input
|
||||
v-model="consentChallengeAnswer"
|
||||
placeholder="Enter the challenge answer"
|
||||
>
|
||||
</el-input>
|
||||
<el-button @click="answerConsentChallenge">Submit</el-button>
|
||||
</div>
|
||||
<div v-if="this.isLoggedIn" v-loading="this.awaitingConnection && !this.awaitingConsentChallengeAnswer" element-loading-text="Awaiting Connection..." class="chat-messages" ref="messages">
|
||||
<div v-for="(message, index) in chatMessages" :key="index" :class="['chat-message', message.role]">
|
||||
<div v-if="(this.isStreaming)&&(index === this.chatMessages.length -1)">
|
||||
<div v-html="renderMarkdown(this.currentMessageSnapshot)"></div>
|
||||
|
||||
13
src/components/ChatWidgetII.vue
Normal file
13
src/components/ChatWidgetII.vue
Normal file
@ -0,0 +1,13 @@
|
||||
<!--
|
||||
placeholder for Opey II Chat widget
|
||||
-->
|
||||
<script setup>
|
||||
import { useChat } from '@ai-sdk/vue'
|
||||
|
||||
const { messages, input, handleInputChange, handleSubmit, addToolResult } = useChat
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
</template>
|
||||
@ -32,6 +32,9 @@ import { getOperationDetails } from '../obp/resource-docs'
|
||||
import { ElNotification, FormInstance } from 'element-plus'
|
||||
import { OBP_API_VERSION, get, create, update, discard, createEntitlement, getCurrentUser } from '../obp'
|
||||
import { obpResourceDocsKey } from '@/obp/keys'
|
||||
import JsonEditorVue from 'json-editor-vue'
|
||||
import { Mode } from 'vanilla-jsoneditor'
|
||||
import 'vanilla-jsoneditor/themes/jse-theme-dark.css'
|
||||
import * as cheerio from 'cheerio'
|
||||
|
||||
const elMessageDuration = 5500
|
||||
@ -41,6 +44,8 @@ const roleName = ref('')
|
||||
const method = ref('')
|
||||
const header = ref('')
|
||||
const responseHeaderTitle = ref('TYPICAL SUCCESSFUL RESPONSE')
|
||||
const exampleBodyTitle = ref('REQUEST BODY')
|
||||
const oldExampleBodyContent = ref('')
|
||||
const successResponseBody = ref('')
|
||||
const exampleRequestBody = ref('')
|
||||
const requiredRoles = ref([])
|
||||
@ -71,7 +76,7 @@ const setOperationDetails = (id: string, version: string): void => {
|
||||
const operation = getOperationDetails(version, id, resourceDocs)
|
||||
url.value = operation?.specified_url
|
||||
method.value = operation?.request_verb
|
||||
exampleRequestBody.value = JSON.stringify(operation.example_request_body)
|
||||
exampleRequestBody.value = operation.example_request_body
|
||||
requiredRoles.value = operation.roles || []
|
||||
possibleErrors.value = operation.error_response_bodies
|
||||
connectorMethods.value = operation.connector_methods
|
||||
@ -120,11 +125,11 @@ const submitRequest = async () => {
|
||||
if (url.value) {
|
||||
switch (method.value) {
|
||||
case 'POST': {
|
||||
highlightCode(await create(url.value, exampleRequestBody.value))
|
||||
highlightCode(await create(url.value, JSON.stringify(exampleRequestBody.value)))
|
||||
break
|
||||
}
|
||||
case 'PUT': {
|
||||
highlightCode(await update(url.value, exampleRequestBody.value))
|
||||
highlightCode(await update(url.value, JSON.stringify(exampleRequestBody.value)))
|
||||
break
|
||||
}
|
||||
case 'DELETE': {
|
||||
@ -234,6 +239,26 @@ const copyToClipboard = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const onJsonEditorChange = (updatedContent) => {
|
||||
oldExampleBodyContent.value = exampleRequestBody.value;
|
||||
try {
|
||||
updatedContent = JSON.parse(JSON.stringify(updatedContent))
|
||||
exampleRequestBody.value = updatedContent;
|
||||
} catch (e) {
|
||||
exampleRequestBody.value = oldExampleBodyContent.value;
|
||||
console.log(`JSON not valid: ${e}`);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const onError = (error) => {
|
||||
console.error(error)
|
||||
try {
|
||||
exampleRequestBody.value = oldExampleBodyContent.value
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
@ -259,8 +284,19 @@ const copyToClipboard = () => {
|
||||
placeholder="Request Header (Header1:Value1::Header2:Value2)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-preview-panel">
|
||||
<input type="text" v-model="exampleRequestBody" />
|
||||
<p v-show="exampleRequestBody" class="header-container">{{ exampleBodyTitle }}:</p>
|
||||
<div class="flex-preview-panel" v-show="exampleRequestBody">
|
||||
<!-- <textarea v-model="exampleRequestBody" rows="8" cols="40"></textarea> -->
|
||||
<!-- <input type="text" v-model="exampleRequestBody" /> -->
|
||||
<!-- <pre>{{ JSON.stringify(exampleRequestBody, null, 2) }}</pre> -->
|
||||
<JsonEditorVue
|
||||
v-model="exampleRequestBody"
|
||||
class="jse-theme-dark"
|
||||
:stringified="true"
|
||||
:mode="Mode.tree"
|
||||
v-bind="{/* local props & attrs */}"
|
||||
:onChange="onJsonEditorChange"
|
||||
/>
|
||||
</div>
|
||||
<div v-show="successResponseBody">
|
||||
<p class="header-container">{{ responseHeaderTitle }}:</p>
|
||||
|
||||
@ -85,6 +85,30 @@ export async function getOpeyJWT() {
|
||||
return token
|
||||
}
|
||||
|
||||
export async function getOpeyConsent() {
|
||||
await axios.post('/api/opey/consent').catch((error) => {
|
||||
if (error.response) {
|
||||
throw new Error(`getOpeyConsent returned an error: ${error.toJSON()}`);
|
||||
} else {
|
||||
throw new Error(`getOpeyConsent returned an error: ${error.message}`);
|
||||
}
|
||||
}).then((response) => {
|
||||
console.log(response)
|
||||
return response
|
||||
});
|
||||
}
|
||||
|
||||
export async function answerOpeyConsentChallenge(answerBody: any) {
|
||||
const response = await axios.post('/api/opey/consent/answer-challenge', answerBody).catch((error) => {
|
||||
if (error.response) {
|
||||
throw new Error(`answerOpeyConsentChallenge returned an error: ${error.toJSON()}`);
|
||||
} else {
|
||||
throw new Error(`answerOpeyConsentChallenge returned an error: ${error.message}`);
|
||||
}
|
||||
});
|
||||
return response
|
||||
}
|
||||
|
||||
export function clearCacheByName(cacheName: string) {
|
||||
if ('caches' in window) {
|
||||
caches.delete(cacheName).then(function(success) {
|
||||
|
||||
120
tests/opey.test.ts
Normal file
120
tests/opey.test.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import { OpeyController } from "../server/controllers/OpeyController";
|
||||
import app from '../server/app';
|
||||
import request from 'supertest';
|
||||
import fetch from 'node-fetch';
|
||||
import http from 'node:http';
|
||||
import { UserInput } from '../server/schema/OpeySchema';
|
||||
import {v4 as uuidv4} from 'uuid';
|
||||
import { agent } from "superagent";
|
||||
|
||||
|
||||
const BEFORE_ALL_TIMEOUT = 30000; // 30 sec
|
||||
const SERVER_URL = process.env.VITE_OBP_API_EXPLORER_HOST
|
||||
|
||||
describe('GET /api/opey', () => {
|
||||
let response: Response;
|
||||
|
||||
it('Should return 200', async () => {
|
||||
const response = await request(app)
|
||||
.get("/api/opey")
|
||||
.set('Content-Type', 'application/json')
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/opey/invoke', () => {
|
||||
let response: Response;
|
||||
|
||||
let userInput: UserInput = {
|
||||
message: "Hello Opey",
|
||||
thread_id: uuidv4(),
|
||||
is_tool_call_approval: false
|
||||
}
|
||||
|
||||
it('Should return 200', async () => {
|
||||
const response = await request(app)
|
||||
.post("/api/opey/invoke")
|
||||
.send(userInput)
|
||||
.set('Content-Type', 'application/json')
|
||||
.then(response => {
|
||||
console.log(`Response: ${response.body}`)
|
||||
expect(response.status).toBe(200);
|
||||
})
|
||||
|
||||
|
||||
});
|
||||
})
|
||||
|
||||
describe('POST /api/opey/stream', () => {
|
||||
|
||||
const httpAgent = new http.Agent({ keepAlive: true, port: 9999 });
|
||||
|
||||
beforeAll(async () => {
|
||||
app.listen(5173)
|
||||
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
app.close()
|
||||
httpAgent.destroy()
|
||||
});
|
||||
|
||||
|
||||
it('Should stream response', async () => {
|
||||
let userInput: UserInput = {
|
||||
message: "Hello Opey",
|
||||
thread_id: uuidv4(),
|
||||
is_tool_call_approval: false
|
||||
}
|
||||
|
||||
|
||||
|
||||
// const response = await request(app)
|
||||
// .post("/api/opey/stream")
|
||||
// .set('Content-Type', 'text/event-stream')
|
||||
// .responseType('blob')
|
||||
// .send(userInput)
|
||||
|
||||
await fetch(`${SERVER_URL}/api/opey/stream`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'connection': 'keep-alive'
|
||||
},
|
||||
body: JSON.stringify(userInput),
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(`Error performing test fetch: ${error}`)
|
||||
})
|
||||
.then(streamingResponse => {
|
||||
console.log(streamingResponse)
|
||||
|
||||
streamingResponse.body.on('data', (chunk) => {
|
||||
console.log(`${chunk}`)
|
||||
})
|
||||
// response.on
|
||||
// console.log(response.body)
|
||||
// const readable = response.body
|
||||
// readable.on('data', (chunk) => {
|
||||
// const data = chunk.toString()
|
||||
// console.log(`data: ${data}`)
|
||||
// })
|
||||
})
|
||||
.finally(() => {
|
||||
httpAgent.destroy()
|
||||
})
|
||||
|
||||
|
||||
|
||||
// while (true) {
|
||||
// const {value, done} = await reader.read();
|
||||
// if (done) break;
|
||||
// console.log('Received', value);
|
||||
// }
|
||||
|
||||
// expect(response.headers['content-type']).toBe('text/event-stream')
|
||||
// expect(response.status).toBe(200)
|
||||
// Optionally, parse chunks or check SSE headers
|
||||
})
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user