Merge pull request #83 from nemozak1/opey-II-integration

Opey ii integration
This commit is contained in:
Simon Redfern 2025-02-12 16:18:39 +01:00 committed by GitHub
commit 6aca5b8b83
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 3803 additions and 174 deletions

6
babel.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
presets: [
['@babel/preset-env', {targets: {node: 'current'}}],
'@babel/preset-typescript',
],
};

1
components.d.ts vendored
View File

@ -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
View 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,
};

View File

@ -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",

View File

@ -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);
}

View File

@ -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.

View 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' });
}
}
}

View 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,
}

View 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}`)
}
}
}

View File

@ -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>

View 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>

View File

@ -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>

View File

@ -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
View 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
})
});

3189
yarn.lock

File diff suppressed because it is too large Load Diff