mirror of
https://github.com/OpenBankProject/API-Explorer-II.git
synced 2026-02-06 10:47:04 +00:00
Merge pull request #100 from nemozak1/develop
Opey II widget integration
This commit is contained in:
commit
00b26065c5
@ -32,6 +32,7 @@ VITE_OBP_REDIS_URL = redis://127.0.0.1:6379
|
||||
# To do this:
|
||||
VITE_CHATBOT_ENABLED=false
|
||||
VITE_CHATBOT_URL=http://localhost:5000
|
||||
VITE_OPEY_CONSUMER_ID=opey_consumer_id # For granting a consent to Opey
|
||||
|
||||
# Product styling setting
|
||||
#VITE_OBP_LINKS_COLOR="#52b165"
|
||||
|
||||
21
.gitignore
vendored
21
.gitignore
vendored
@ -31,18 +31,33 @@ coverage
|
||||
*.sw?
|
||||
|
||||
#files from npm-run-build
|
||||
package-lock.json
|
||||
tsconfig.app.tsbuildinfo
|
||||
tsconfig.vitest.tsbuildinfo
|
||||
tsconfig.node.tsbuildinfo
|
||||
vite.config.d.ts
|
||||
vite.config.js
|
||||
vitest.config.d.ts
|
||||
vitest.config.js
|
||||
components.d.ts
|
||||
|
||||
#keys
|
||||
*.pem
|
||||
private_key.pem
|
||||
public_key.pem
|
||||
./server/cert/*
|
||||
./server/cert/*
|
||||
|
||||
#playwright
|
||||
playwright-report/
|
||||
.auth/
|
||||
test-results/
|
||||
__snapshots__/
|
||||
|
||||
.vite/deps
|
||||
__snapshots__/
|
||||
|
||||
# Playwright auth
|
||||
src/test/integration/playwright/.auth
|
||||
src/test/integration/playwright/.auth/
|
||||
**/playwright/.auth/
|
||||
test-results/
|
||||
playwright-report/
|
||||
playwright-coverage/
|
||||
|
||||
@ -1,593 +0,0 @@
|
||||
import {
|
||||
add_location_default,
|
||||
aim_default,
|
||||
alarm_clock_default,
|
||||
apple_default,
|
||||
arrow_down_bold_default,
|
||||
arrow_down_default,
|
||||
arrow_left_bold_default,
|
||||
arrow_left_default,
|
||||
arrow_right_bold_default,
|
||||
arrow_right_default,
|
||||
arrow_up_bold_default,
|
||||
arrow_up_default,
|
||||
avatar_default,
|
||||
back_default,
|
||||
baseball_default,
|
||||
basketball_default,
|
||||
bell_default,
|
||||
bell_filled_default,
|
||||
bicycle_default,
|
||||
bottom_default,
|
||||
bottom_left_default,
|
||||
bottom_right_default,
|
||||
bowl_default,
|
||||
box_default,
|
||||
briefcase_default,
|
||||
brush_default,
|
||||
brush_filled_default,
|
||||
burger_default,
|
||||
calendar_default,
|
||||
camera_default,
|
||||
camera_filled_default,
|
||||
caret_bottom_default,
|
||||
caret_left_default,
|
||||
caret_right_default,
|
||||
caret_top_default,
|
||||
cellphone_default,
|
||||
chat_dot_round_default,
|
||||
chat_dot_square_default,
|
||||
chat_line_round_default,
|
||||
chat_line_square_default,
|
||||
chat_round_default,
|
||||
chat_square_default,
|
||||
check_default,
|
||||
checked_default,
|
||||
cherry_default,
|
||||
chicken_default,
|
||||
chrome_filled_default,
|
||||
circle_check_default,
|
||||
circle_check_filled_default,
|
||||
circle_close_default,
|
||||
circle_close_filled_default,
|
||||
circle_plus_default,
|
||||
circle_plus_filled_default,
|
||||
clock_default,
|
||||
close_bold_default,
|
||||
close_default,
|
||||
cloudy_default,
|
||||
coffee_cup_default,
|
||||
coffee_default,
|
||||
coin_default,
|
||||
cold_drink_default,
|
||||
collection_default,
|
||||
collection_tag_default,
|
||||
comment_default,
|
||||
compass_default,
|
||||
connection_default,
|
||||
coordinate_default,
|
||||
copy_document_default,
|
||||
cpu_default,
|
||||
credit_card_default,
|
||||
crop_default,
|
||||
d_arrow_left_default,
|
||||
d_arrow_right_default,
|
||||
d_caret_default,
|
||||
data_analysis_default,
|
||||
data_board_default,
|
||||
data_line_default,
|
||||
delete_default,
|
||||
delete_filled_default,
|
||||
delete_location_default,
|
||||
dessert_default,
|
||||
discount_default,
|
||||
dish_default,
|
||||
dish_dot_default,
|
||||
document_add_default,
|
||||
document_checked_default,
|
||||
document_copy_default,
|
||||
document_default,
|
||||
document_delete_default,
|
||||
document_remove_default,
|
||||
download_default,
|
||||
drizzling_default,
|
||||
edit_default,
|
||||
edit_pen_default,
|
||||
eleme_default,
|
||||
eleme_filled_default,
|
||||
element_plus_default,
|
||||
expand_default,
|
||||
failed_default,
|
||||
female_default,
|
||||
files_default,
|
||||
film_default,
|
||||
filter_default,
|
||||
finished_default,
|
||||
first_aid_kit_default,
|
||||
flag_default,
|
||||
fold_default,
|
||||
folder_add_default,
|
||||
folder_checked_default,
|
||||
folder_default,
|
||||
folder_delete_default,
|
||||
folder_opened_default,
|
||||
folder_remove_default,
|
||||
food_default,
|
||||
football_default,
|
||||
fork_spoon_default,
|
||||
fries_default,
|
||||
full_screen_default,
|
||||
goblet_default,
|
||||
goblet_full_default,
|
||||
goblet_square_default,
|
||||
goblet_square_full_default,
|
||||
gold_medal_default,
|
||||
goods_default,
|
||||
goods_filled_default,
|
||||
grape_default,
|
||||
grid_default,
|
||||
guide_default,
|
||||
handbag_default,
|
||||
headset_default,
|
||||
help_default,
|
||||
help_filled_default,
|
||||
hide_default,
|
||||
histogram_default,
|
||||
home_filled_default,
|
||||
hot_water_default,
|
||||
house_default,
|
||||
ice_cream_default,
|
||||
ice_cream_round_default,
|
||||
ice_cream_square_default,
|
||||
ice_drink_default,
|
||||
ice_tea_default,
|
||||
info_filled_default,
|
||||
iphone_default,
|
||||
key_default,
|
||||
knife_fork_default,
|
||||
lightning_default,
|
||||
link_default,
|
||||
list_default,
|
||||
loading_default,
|
||||
location_default,
|
||||
location_filled_default,
|
||||
location_information_default,
|
||||
lock_default,
|
||||
lollipop_default,
|
||||
magic_stick_default,
|
||||
magnet_default,
|
||||
male_default,
|
||||
management_default,
|
||||
map_location_default,
|
||||
medal_default,
|
||||
memo_default,
|
||||
menu_default,
|
||||
message_box_default,
|
||||
message_default,
|
||||
mic_default,
|
||||
microphone_default,
|
||||
milk_tea_default,
|
||||
minus_default,
|
||||
money_default,
|
||||
monitor_default,
|
||||
moon_default,
|
||||
moon_night_default,
|
||||
more_default,
|
||||
more_filled_default,
|
||||
mostly_cloudy_default,
|
||||
mouse_default,
|
||||
mug_default,
|
||||
mute_default,
|
||||
mute_notification_default,
|
||||
no_smoking_default,
|
||||
notebook_default,
|
||||
notification_default,
|
||||
odometer_default,
|
||||
office_building_default,
|
||||
open_default,
|
||||
operation_default,
|
||||
opportunity_default,
|
||||
orange_default,
|
||||
paperclip_default,
|
||||
partly_cloudy_default,
|
||||
pear_default,
|
||||
phone_default,
|
||||
phone_filled_default,
|
||||
picture_default,
|
||||
picture_filled_default,
|
||||
picture_rounded_default,
|
||||
pie_chart_default,
|
||||
place_default,
|
||||
platform_default,
|
||||
plus_default,
|
||||
pointer_default,
|
||||
position_default,
|
||||
postcard_default,
|
||||
pouring_default,
|
||||
present_default,
|
||||
price_tag_default,
|
||||
printer_default,
|
||||
promotion_default,
|
||||
quartz_watch_default,
|
||||
question_filled_default,
|
||||
rank_default,
|
||||
reading_default,
|
||||
reading_lamp_default,
|
||||
refresh_default,
|
||||
refresh_left_default,
|
||||
refresh_right_default,
|
||||
refrigerator_default,
|
||||
remove_default,
|
||||
remove_filled_default,
|
||||
right_default,
|
||||
scale_to_original_default,
|
||||
school_default,
|
||||
scissor_default,
|
||||
search_default,
|
||||
select_default,
|
||||
sell_default,
|
||||
semi_select_default,
|
||||
service_default,
|
||||
set_up_default,
|
||||
setting_default,
|
||||
share_default,
|
||||
ship_default,
|
||||
shop_default,
|
||||
shopping_bag_default,
|
||||
shopping_cart_default,
|
||||
shopping_cart_full_default,
|
||||
shopping_trolley_default,
|
||||
smoking_default,
|
||||
soccer_default,
|
||||
sold_out_default,
|
||||
sort_default,
|
||||
sort_down_default,
|
||||
sort_up_default,
|
||||
stamp_default,
|
||||
star_default,
|
||||
star_filled_default,
|
||||
stopwatch_default,
|
||||
success_filled_default,
|
||||
sugar_default,
|
||||
suitcase_default,
|
||||
suitcase_line_default,
|
||||
sunny_default,
|
||||
sunrise_default,
|
||||
sunset_default,
|
||||
switch_button_default,
|
||||
switch_default,
|
||||
switch_filled_default,
|
||||
takeaway_box_default,
|
||||
ticket_default,
|
||||
tickets_default,
|
||||
timer_default,
|
||||
toilet_paper_default,
|
||||
tools_default,
|
||||
top_default,
|
||||
top_left_default,
|
||||
top_right_default,
|
||||
trend_charts_default,
|
||||
trophy_base_default,
|
||||
trophy_default,
|
||||
turn_off_default,
|
||||
umbrella_default,
|
||||
unlock_default,
|
||||
upload_default,
|
||||
upload_filled_default,
|
||||
user_default,
|
||||
user_filled_default,
|
||||
van_default,
|
||||
video_camera_default,
|
||||
video_camera_filled_default,
|
||||
video_pause_default,
|
||||
video_play_default,
|
||||
view_default,
|
||||
wallet_default,
|
||||
wallet_filled_default,
|
||||
warn_triangle_filled_default,
|
||||
warning_default,
|
||||
warning_filled_default,
|
||||
watch_default,
|
||||
watermelon_default,
|
||||
wind_power_default,
|
||||
zoom_in_default,
|
||||
zoom_out_default
|
||||
} from "./chunk-SDH6OONA.js";
|
||||
import "./chunk-PCZZZCUV.js";
|
||||
import "./chunk-5WWUZCGV.js";
|
||||
export {
|
||||
add_location_default as AddLocation,
|
||||
aim_default as Aim,
|
||||
alarm_clock_default as AlarmClock,
|
||||
apple_default as Apple,
|
||||
arrow_down_default as ArrowDown,
|
||||
arrow_down_bold_default as ArrowDownBold,
|
||||
arrow_left_default as ArrowLeft,
|
||||
arrow_left_bold_default as ArrowLeftBold,
|
||||
arrow_right_default as ArrowRight,
|
||||
arrow_right_bold_default as ArrowRightBold,
|
||||
arrow_up_default as ArrowUp,
|
||||
arrow_up_bold_default as ArrowUpBold,
|
||||
avatar_default as Avatar,
|
||||
back_default as Back,
|
||||
baseball_default as Baseball,
|
||||
basketball_default as Basketball,
|
||||
bell_default as Bell,
|
||||
bell_filled_default as BellFilled,
|
||||
bicycle_default as Bicycle,
|
||||
bottom_default as Bottom,
|
||||
bottom_left_default as BottomLeft,
|
||||
bottom_right_default as BottomRight,
|
||||
bowl_default as Bowl,
|
||||
box_default as Box,
|
||||
briefcase_default as Briefcase,
|
||||
brush_default as Brush,
|
||||
brush_filled_default as BrushFilled,
|
||||
burger_default as Burger,
|
||||
calendar_default as Calendar,
|
||||
camera_default as Camera,
|
||||
camera_filled_default as CameraFilled,
|
||||
caret_bottom_default as CaretBottom,
|
||||
caret_left_default as CaretLeft,
|
||||
caret_right_default as CaretRight,
|
||||
caret_top_default as CaretTop,
|
||||
cellphone_default as Cellphone,
|
||||
chat_dot_round_default as ChatDotRound,
|
||||
chat_dot_square_default as ChatDotSquare,
|
||||
chat_line_round_default as ChatLineRound,
|
||||
chat_line_square_default as ChatLineSquare,
|
||||
chat_round_default as ChatRound,
|
||||
chat_square_default as ChatSquare,
|
||||
check_default as Check,
|
||||
checked_default as Checked,
|
||||
cherry_default as Cherry,
|
||||
chicken_default as Chicken,
|
||||
chrome_filled_default as ChromeFilled,
|
||||
circle_check_default as CircleCheck,
|
||||
circle_check_filled_default as CircleCheckFilled,
|
||||
circle_close_default as CircleClose,
|
||||
circle_close_filled_default as CircleCloseFilled,
|
||||
circle_plus_default as CirclePlus,
|
||||
circle_plus_filled_default as CirclePlusFilled,
|
||||
clock_default as Clock,
|
||||
close_default as Close,
|
||||
close_bold_default as CloseBold,
|
||||
cloudy_default as Cloudy,
|
||||
coffee_default as Coffee,
|
||||
coffee_cup_default as CoffeeCup,
|
||||
coin_default as Coin,
|
||||
cold_drink_default as ColdDrink,
|
||||
collection_default as Collection,
|
||||
collection_tag_default as CollectionTag,
|
||||
comment_default as Comment,
|
||||
compass_default as Compass,
|
||||
connection_default as Connection,
|
||||
coordinate_default as Coordinate,
|
||||
copy_document_default as CopyDocument,
|
||||
cpu_default as Cpu,
|
||||
credit_card_default as CreditCard,
|
||||
crop_default as Crop,
|
||||
d_arrow_left_default as DArrowLeft,
|
||||
d_arrow_right_default as DArrowRight,
|
||||
d_caret_default as DCaret,
|
||||
data_analysis_default as DataAnalysis,
|
||||
data_board_default as DataBoard,
|
||||
data_line_default as DataLine,
|
||||
delete_default as Delete,
|
||||
delete_filled_default as DeleteFilled,
|
||||
delete_location_default as DeleteLocation,
|
||||
dessert_default as Dessert,
|
||||
discount_default as Discount,
|
||||
dish_default as Dish,
|
||||
dish_dot_default as DishDot,
|
||||
document_default as Document,
|
||||
document_add_default as DocumentAdd,
|
||||
document_checked_default as DocumentChecked,
|
||||
document_copy_default as DocumentCopy,
|
||||
document_delete_default as DocumentDelete,
|
||||
document_remove_default as DocumentRemove,
|
||||
download_default as Download,
|
||||
drizzling_default as Drizzling,
|
||||
edit_default as Edit,
|
||||
edit_pen_default as EditPen,
|
||||
eleme_default as Eleme,
|
||||
eleme_filled_default as ElemeFilled,
|
||||
element_plus_default as ElementPlus,
|
||||
expand_default as Expand,
|
||||
failed_default as Failed,
|
||||
female_default as Female,
|
||||
files_default as Files,
|
||||
film_default as Film,
|
||||
filter_default as Filter,
|
||||
finished_default as Finished,
|
||||
first_aid_kit_default as FirstAidKit,
|
||||
flag_default as Flag,
|
||||
fold_default as Fold,
|
||||
folder_default as Folder,
|
||||
folder_add_default as FolderAdd,
|
||||
folder_checked_default as FolderChecked,
|
||||
folder_delete_default as FolderDelete,
|
||||
folder_opened_default as FolderOpened,
|
||||
folder_remove_default as FolderRemove,
|
||||
food_default as Food,
|
||||
football_default as Football,
|
||||
fork_spoon_default as ForkSpoon,
|
||||
fries_default as Fries,
|
||||
full_screen_default as FullScreen,
|
||||
goblet_default as Goblet,
|
||||
goblet_full_default as GobletFull,
|
||||
goblet_square_default as GobletSquare,
|
||||
goblet_square_full_default as GobletSquareFull,
|
||||
gold_medal_default as GoldMedal,
|
||||
goods_default as Goods,
|
||||
goods_filled_default as GoodsFilled,
|
||||
grape_default as Grape,
|
||||
grid_default as Grid,
|
||||
guide_default as Guide,
|
||||
handbag_default as Handbag,
|
||||
headset_default as Headset,
|
||||
help_default as Help,
|
||||
help_filled_default as HelpFilled,
|
||||
hide_default as Hide,
|
||||
histogram_default as Histogram,
|
||||
home_filled_default as HomeFilled,
|
||||
hot_water_default as HotWater,
|
||||
house_default as House,
|
||||
ice_cream_default as IceCream,
|
||||
ice_cream_round_default as IceCreamRound,
|
||||
ice_cream_square_default as IceCreamSquare,
|
||||
ice_drink_default as IceDrink,
|
||||
ice_tea_default as IceTea,
|
||||
info_filled_default as InfoFilled,
|
||||
iphone_default as Iphone,
|
||||
key_default as Key,
|
||||
knife_fork_default as KnifeFork,
|
||||
lightning_default as Lightning,
|
||||
link_default as Link,
|
||||
list_default as List,
|
||||
loading_default as Loading,
|
||||
location_default as Location,
|
||||
location_filled_default as LocationFilled,
|
||||
location_information_default as LocationInformation,
|
||||
lock_default as Lock,
|
||||
lollipop_default as Lollipop,
|
||||
magic_stick_default as MagicStick,
|
||||
magnet_default as Magnet,
|
||||
male_default as Male,
|
||||
management_default as Management,
|
||||
map_location_default as MapLocation,
|
||||
medal_default as Medal,
|
||||
memo_default as Memo,
|
||||
menu_default as Menu,
|
||||
message_default as Message,
|
||||
message_box_default as MessageBox,
|
||||
mic_default as Mic,
|
||||
microphone_default as Microphone,
|
||||
milk_tea_default as MilkTea,
|
||||
minus_default as Minus,
|
||||
money_default as Money,
|
||||
monitor_default as Monitor,
|
||||
moon_default as Moon,
|
||||
moon_night_default as MoonNight,
|
||||
more_default as More,
|
||||
more_filled_default as MoreFilled,
|
||||
mostly_cloudy_default as MostlyCloudy,
|
||||
mouse_default as Mouse,
|
||||
mug_default as Mug,
|
||||
mute_default as Mute,
|
||||
mute_notification_default as MuteNotification,
|
||||
no_smoking_default as NoSmoking,
|
||||
notebook_default as Notebook,
|
||||
notification_default as Notification,
|
||||
odometer_default as Odometer,
|
||||
office_building_default as OfficeBuilding,
|
||||
open_default as Open,
|
||||
operation_default as Operation,
|
||||
opportunity_default as Opportunity,
|
||||
orange_default as Orange,
|
||||
paperclip_default as Paperclip,
|
||||
partly_cloudy_default as PartlyCloudy,
|
||||
pear_default as Pear,
|
||||
phone_default as Phone,
|
||||
phone_filled_default as PhoneFilled,
|
||||
picture_default as Picture,
|
||||
picture_filled_default as PictureFilled,
|
||||
picture_rounded_default as PictureRounded,
|
||||
pie_chart_default as PieChart,
|
||||
place_default as Place,
|
||||
platform_default as Platform,
|
||||
plus_default as Plus,
|
||||
pointer_default as Pointer,
|
||||
position_default as Position,
|
||||
postcard_default as Postcard,
|
||||
pouring_default as Pouring,
|
||||
present_default as Present,
|
||||
price_tag_default as PriceTag,
|
||||
printer_default as Printer,
|
||||
promotion_default as Promotion,
|
||||
quartz_watch_default as QuartzWatch,
|
||||
question_filled_default as QuestionFilled,
|
||||
rank_default as Rank,
|
||||
reading_default as Reading,
|
||||
reading_lamp_default as ReadingLamp,
|
||||
refresh_default as Refresh,
|
||||
refresh_left_default as RefreshLeft,
|
||||
refresh_right_default as RefreshRight,
|
||||
refrigerator_default as Refrigerator,
|
||||
remove_default as Remove,
|
||||
remove_filled_default as RemoveFilled,
|
||||
right_default as Right,
|
||||
scale_to_original_default as ScaleToOriginal,
|
||||
school_default as School,
|
||||
scissor_default as Scissor,
|
||||
search_default as Search,
|
||||
select_default as Select,
|
||||
sell_default as Sell,
|
||||
semi_select_default as SemiSelect,
|
||||
service_default as Service,
|
||||
set_up_default as SetUp,
|
||||
setting_default as Setting,
|
||||
share_default as Share,
|
||||
ship_default as Ship,
|
||||
shop_default as Shop,
|
||||
shopping_bag_default as ShoppingBag,
|
||||
shopping_cart_default as ShoppingCart,
|
||||
shopping_cart_full_default as ShoppingCartFull,
|
||||
shopping_trolley_default as ShoppingTrolley,
|
||||
smoking_default as Smoking,
|
||||
soccer_default as Soccer,
|
||||
sold_out_default as SoldOut,
|
||||
sort_default as Sort,
|
||||
sort_down_default as SortDown,
|
||||
sort_up_default as SortUp,
|
||||
stamp_default as Stamp,
|
||||
star_default as Star,
|
||||
star_filled_default as StarFilled,
|
||||
stopwatch_default as Stopwatch,
|
||||
success_filled_default as SuccessFilled,
|
||||
sugar_default as Sugar,
|
||||
suitcase_default as Suitcase,
|
||||
suitcase_line_default as SuitcaseLine,
|
||||
sunny_default as Sunny,
|
||||
sunrise_default as Sunrise,
|
||||
sunset_default as Sunset,
|
||||
switch_default as Switch,
|
||||
switch_button_default as SwitchButton,
|
||||
switch_filled_default as SwitchFilled,
|
||||
takeaway_box_default as TakeawayBox,
|
||||
ticket_default as Ticket,
|
||||
tickets_default as Tickets,
|
||||
timer_default as Timer,
|
||||
toilet_paper_default as ToiletPaper,
|
||||
tools_default as Tools,
|
||||
top_default as Top,
|
||||
top_left_default as TopLeft,
|
||||
top_right_default as TopRight,
|
||||
trend_charts_default as TrendCharts,
|
||||
trophy_default as Trophy,
|
||||
trophy_base_default as TrophyBase,
|
||||
turn_off_default as TurnOff,
|
||||
umbrella_default as Umbrella,
|
||||
unlock_default as Unlock,
|
||||
upload_default as Upload,
|
||||
upload_filled_default as UploadFilled,
|
||||
user_default as User,
|
||||
user_filled_default as UserFilled,
|
||||
van_default as Van,
|
||||
video_camera_default as VideoCamera,
|
||||
video_camera_filled_default as VideoCameraFilled,
|
||||
video_pause_default as VideoPause,
|
||||
video_play_default as VideoPlay,
|
||||
view_default as View,
|
||||
wallet_default as Wallet,
|
||||
wallet_filled_default as WalletFilled,
|
||||
warn_triangle_filled_default as WarnTriangleFilled,
|
||||
warning_default as Warning,
|
||||
warning_filled_default as WarningFilled,
|
||||
watch_default as Watch,
|
||||
watermelon_default as Watermelon,
|
||||
wind_power_default as WindPower,
|
||||
zoom_in_default as ZoomIn,
|
||||
zoom_out_default as ZoomOut
|
||||
};
|
||||
//# sourceMappingURL=@element-plus_icons-vue.js.map
|
||||
@ -1,7 +0,0 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": [],
|
||||
"sourcesContent": [],
|
||||
"mappings": "",
|
||||
"names": []
|
||||
}
|
||||
@ -1,68 +0,0 @@
|
||||
{
|
||||
"hash": "effb5868",
|
||||
"browserHash": "737f5109",
|
||||
"optimized": {
|
||||
"vue": {
|
||||
"src": "../../node_modules/vue/dist/vue.runtime.esm-bundler.js",
|
||||
"file": "vue.js",
|
||||
"fileHash": "43ef0589",
|
||||
"needsInterop": false
|
||||
},
|
||||
"pinia": {
|
||||
"src": "../../node_modules/pinia/dist/pinia.mjs",
|
||||
"file": "pinia.js",
|
||||
"fileHash": "8e8113cf",
|
||||
"needsInterop": false
|
||||
},
|
||||
"element-plus": {
|
||||
"src": "../../node_modules/element-plus/es/index.mjs",
|
||||
"file": "element-plus.js",
|
||||
"fileHash": "96670016",
|
||||
"needsInterop": false
|
||||
},
|
||||
"@element-plus/icons-vue": {
|
||||
"src": "../../node_modules/@element-plus/icons-vue/dist/index.js",
|
||||
"file": "@element-plus_icons-vue.js",
|
||||
"fileHash": "cb3abdb5",
|
||||
"needsInterop": false
|
||||
},
|
||||
"vue-i18n": {
|
||||
"src": "../../node_modules/vue-i18n/dist/vue-i18n.mjs",
|
||||
"file": "vue-i18n.js",
|
||||
"fileHash": "ec7985ae",
|
||||
"needsInterop": false
|
||||
},
|
||||
"vue-router": {
|
||||
"src": "../../node_modules/vue-router/dist/vue-router.mjs",
|
||||
"file": "vue-router.js",
|
||||
"fileHash": "eda2ca0e",
|
||||
"needsInterop": false
|
||||
},
|
||||
"superagent": {
|
||||
"src": "../../node_modules/superagent/lib/client.js",
|
||||
"file": "superagent.js",
|
||||
"fileHash": "280c4f78",
|
||||
"needsInterop": true
|
||||
},
|
||||
"axios": {
|
||||
"src": "../../node_modules/axios/index.js",
|
||||
"file": "axios.js",
|
||||
"fileHash": "f18ae2eb",
|
||||
"needsInterop": false
|
||||
}
|
||||
},
|
||||
"chunks": {
|
||||
"chunk-SDH6OONA": {
|
||||
"file": "chunk-SDH6OONA.js"
|
||||
},
|
||||
"chunk-AYVSL3LM": {
|
||||
"file": "chunk-AYVSL3LM.js"
|
||||
},
|
||||
"chunk-PCZZZCUV": {
|
||||
"file": "chunk-PCZZZCUV.js"
|
||||
},
|
||||
"chunk-5WWUZCGV": {
|
||||
"file": "chunk-5WWUZCGV.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
2531
.vite/deps/axios.js
2531
.vite/deps/axios.js
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@ -1,36 +0,0 @@
|
||||
var __create = Object.create;
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __getProtoOf = Object.getPrototypeOf;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __commonJS = (cb, mod) => function __require() {
|
||||
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
||||
};
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
||||
// If the importer is in node compatibility mode or this is not an ESM
|
||||
// file that has been converted to a CommonJS file using a Babel-
|
||||
// compatible transform (i.e. "__esModule" has not been set), then set
|
||||
// "default" to the CommonJS "module.exports" for node compatibility.
|
||||
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
||||
mod
|
||||
));
|
||||
|
||||
export {
|
||||
__commonJS,
|
||||
__export,
|
||||
__toESM
|
||||
};
|
||||
//# sourceMappingURL=chunk-5WWUZCGV.js.map
|
||||
@ -1,7 +0,0 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": [],
|
||||
"sourcesContent": [],
|
||||
"mappings": "",
|
||||
"names": []
|
||||
}
|
||||
@ -1,162 +0,0 @@
|
||||
// node_modules/@vue/devtools-api/lib/esm/env.js
|
||||
function getDevtoolsGlobalHook() {
|
||||
return getTarget().__VUE_DEVTOOLS_GLOBAL_HOOK__;
|
||||
}
|
||||
function getTarget() {
|
||||
return typeof navigator !== "undefined" && typeof window !== "undefined" ? window : typeof globalThis !== "undefined" ? globalThis : {};
|
||||
}
|
||||
var isProxyAvailable = typeof Proxy === "function";
|
||||
|
||||
// node_modules/@vue/devtools-api/lib/esm/const.js
|
||||
var HOOK_SETUP = "devtools-plugin:setup";
|
||||
var HOOK_PLUGIN_SETTINGS_SET = "plugin:settings:set";
|
||||
|
||||
// node_modules/@vue/devtools-api/lib/esm/time.js
|
||||
var supported;
|
||||
var perf;
|
||||
function isPerformanceSupported() {
|
||||
var _a;
|
||||
if (supported !== void 0) {
|
||||
return supported;
|
||||
}
|
||||
if (typeof window !== "undefined" && window.performance) {
|
||||
supported = true;
|
||||
perf = window.performance;
|
||||
} else if (typeof globalThis !== "undefined" && ((_a = globalThis.perf_hooks) === null || _a === void 0 ? void 0 : _a.performance)) {
|
||||
supported = true;
|
||||
perf = globalThis.perf_hooks.performance;
|
||||
} else {
|
||||
supported = false;
|
||||
}
|
||||
return supported;
|
||||
}
|
||||
function now() {
|
||||
return isPerformanceSupported() ? perf.now() : Date.now();
|
||||
}
|
||||
|
||||
// node_modules/@vue/devtools-api/lib/esm/proxy.js
|
||||
var ApiProxy = class {
|
||||
constructor(plugin, hook) {
|
||||
this.target = null;
|
||||
this.targetQueue = [];
|
||||
this.onQueue = [];
|
||||
this.plugin = plugin;
|
||||
this.hook = hook;
|
||||
const defaultSettings = {};
|
||||
if (plugin.settings) {
|
||||
for (const id in plugin.settings) {
|
||||
const item = plugin.settings[id];
|
||||
defaultSettings[id] = item.defaultValue;
|
||||
}
|
||||
}
|
||||
const localSettingsSaveId = `__vue-devtools-plugin-settings__${plugin.id}`;
|
||||
let currentSettings = Object.assign({}, defaultSettings);
|
||||
try {
|
||||
const raw = localStorage.getItem(localSettingsSaveId);
|
||||
const data = JSON.parse(raw);
|
||||
Object.assign(currentSettings, data);
|
||||
} catch (e) {
|
||||
}
|
||||
this.fallbacks = {
|
||||
getSettings() {
|
||||
return currentSettings;
|
||||
},
|
||||
setSettings(value) {
|
||||
try {
|
||||
localStorage.setItem(localSettingsSaveId, JSON.stringify(value));
|
||||
} catch (e) {
|
||||
}
|
||||
currentSettings = value;
|
||||
},
|
||||
now() {
|
||||
return now();
|
||||
}
|
||||
};
|
||||
if (hook) {
|
||||
hook.on(HOOK_PLUGIN_SETTINGS_SET, (pluginId, value) => {
|
||||
if (pluginId === this.plugin.id) {
|
||||
this.fallbacks.setSettings(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.proxiedOn = new Proxy({}, {
|
||||
get: (_target, prop) => {
|
||||
if (this.target) {
|
||||
return this.target.on[prop];
|
||||
} else {
|
||||
return (...args) => {
|
||||
this.onQueue.push({
|
||||
method: prop,
|
||||
args
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
this.proxiedTarget = new Proxy({}, {
|
||||
get: (_target, prop) => {
|
||||
if (this.target) {
|
||||
return this.target[prop];
|
||||
} else if (prop === "on") {
|
||||
return this.proxiedOn;
|
||||
} else if (Object.keys(this.fallbacks).includes(prop)) {
|
||||
return (...args) => {
|
||||
this.targetQueue.push({
|
||||
method: prop,
|
||||
args,
|
||||
resolve: () => {
|
||||
}
|
||||
});
|
||||
return this.fallbacks[prop](...args);
|
||||
};
|
||||
} else {
|
||||
return (...args) => {
|
||||
return new Promise((resolve) => {
|
||||
this.targetQueue.push({
|
||||
method: prop,
|
||||
args,
|
||||
resolve
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
async setRealTarget(target) {
|
||||
this.target = target;
|
||||
for (const item of this.onQueue) {
|
||||
this.target.on[item.method](...item.args);
|
||||
}
|
||||
for (const item of this.targetQueue) {
|
||||
item.resolve(await this.target[item.method](...item.args));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// node_modules/@vue/devtools-api/lib/esm/index.js
|
||||
function setupDevtoolsPlugin(pluginDescriptor, setupFn) {
|
||||
const descriptor = pluginDescriptor;
|
||||
const target = getTarget();
|
||||
const hook = getDevtoolsGlobalHook();
|
||||
const enableProxy = isProxyAvailable && descriptor.enableEarlyProxy;
|
||||
if (hook && (target.__VUE_DEVTOOLS_PLUGIN_API_AVAILABLE__ || !enableProxy)) {
|
||||
hook.emit(HOOK_SETUP, pluginDescriptor, setupFn);
|
||||
} else {
|
||||
const proxy = enableProxy ? new ApiProxy(descriptor, hook) : null;
|
||||
const list = target.__VUE_DEVTOOLS_PLUGINS__ = target.__VUE_DEVTOOLS_PLUGINS__ || [];
|
||||
list.push({
|
||||
pluginDescriptor: descriptor,
|
||||
setupFn,
|
||||
proxy
|
||||
});
|
||||
if (proxy) {
|
||||
setupFn(proxy.proxiedTarget);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
setupDevtoolsPlugin
|
||||
};
|
||||
//# sourceMappingURL=chunk-AYVSL3LM.js.map
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@ -1,3 +0,0 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
1561
.vite/deps/pinia.js
1561
.vite/deps/pinia.js
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@ -1,344 +0,0 @@
|
||||
import {
|
||||
BaseTransition,
|
||||
BaseTransitionPropsValidators,
|
||||
Comment,
|
||||
DeprecationTypes,
|
||||
EffectScope,
|
||||
ErrorCodes,
|
||||
ErrorTypeStrings,
|
||||
Fragment,
|
||||
KeepAlive,
|
||||
ReactiveEffect,
|
||||
Static,
|
||||
Suspense,
|
||||
Teleport,
|
||||
Text,
|
||||
TrackOpTypes,
|
||||
Transition,
|
||||
TransitionGroup,
|
||||
TriggerOpTypes,
|
||||
VueElement,
|
||||
assertNumber,
|
||||
callWithAsyncErrorHandling,
|
||||
callWithErrorHandling,
|
||||
camelize,
|
||||
capitalize,
|
||||
cloneVNode,
|
||||
compatUtils,
|
||||
compile,
|
||||
computed,
|
||||
createApp,
|
||||
createBaseVNode,
|
||||
createBlock,
|
||||
createCommentVNode,
|
||||
createElementBlock,
|
||||
createHydrationRenderer,
|
||||
createPropsRestProxy,
|
||||
createRenderer,
|
||||
createSSRApp,
|
||||
createSlots,
|
||||
createStaticVNode,
|
||||
createTextVNode,
|
||||
createVNode,
|
||||
customRef,
|
||||
defineAsyncComponent,
|
||||
defineComponent,
|
||||
defineCustomElement,
|
||||
defineEmits,
|
||||
defineExpose,
|
||||
defineModel,
|
||||
defineOptions,
|
||||
defineProps,
|
||||
defineSSRCustomElement,
|
||||
defineSlots,
|
||||
devtools,
|
||||
effect,
|
||||
effectScope,
|
||||
getCurrentInstance,
|
||||
getCurrentScope,
|
||||
getCurrentWatcher,
|
||||
getTransitionRawChildren,
|
||||
guardReactiveProps,
|
||||
h,
|
||||
handleError,
|
||||
hasInjectionContext,
|
||||
hydrate,
|
||||
hydrateOnIdle,
|
||||
hydrateOnInteraction,
|
||||
hydrateOnMediaQuery,
|
||||
hydrateOnVisible,
|
||||
initCustomFormatter,
|
||||
initDirectivesForSSR,
|
||||
inject,
|
||||
isMemoSame,
|
||||
isProxy,
|
||||
isReactive,
|
||||
isReadonly,
|
||||
isRef,
|
||||
isRuntimeOnly,
|
||||
isShallow,
|
||||
isVNode,
|
||||
markRaw,
|
||||
mergeDefaults,
|
||||
mergeModels,
|
||||
mergeProps,
|
||||
nextTick,
|
||||
normalizeClass,
|
||||
normalizeProps,
|
||||
normalizeStyle,
|
||||
onActivated,
|
||||
onBeforeMount,
|
||||
onBeforeUnmount,
|
||||
onBeforeUpdate,
|
||||
onDeactivated,
|
||||
onErrorCaptured,
|
||||
onMounted,
|
||||
onRenderTracked,
|
||||
onRenderTriggered,
|
||||
onScopeDispose,
|
||||
onServerPrefetch,
|
||||
onUnmounted,
|
||||
onUpdated,
|
||||
onWatcherCleanup,
|
||||
openBlock,
|
||||
popScopeId,
|
||||
provide,
|
||||
proxyRefs,
|
||||
pushScopeId,
|
||||
queuePostFlushCb,
|
||||
reactive,
|
||||
readonly,
|
||||
ref,
|
||||
registerRuntimeCompiler,
|
||||
render,
|
||||
renderList,
|
||||
renderSlot,
|
||||
resolveComponent,
|
||||
resolveDirective,
|
||||
resolveDynamicComponent,
|
||||
resolveFilter,
|
||||
resolveTransitionHooks,
|
||||
setBlockTracking,
|
||||
setDevtoolsHook,
|
||||
setTransitionHooks,
|
||||
shallowReactive,
|
||||
shallowReadonly,
|
||||
shallowRef,
|
||||
ssrContextKey,
|
||||
ssrUtils,
|
||||
stop,
|
||||
toDisplayString,
|
||||
toHandlerKey,
|
||||
toHandlers,
|
||||
toRaw,
|
||||
toRef,
|
||||
toRefs,
|
||||
toValue,
|
||||
transformVNodeArgs,
|
||||
triggerRef,
|
||||
unref,
|
||||
useAttrs,
|
||||
useCssModule,
|
||||
useCssVars,
|
||||
useHost,
|
||||
useId,
|
||||
useModel,
|
||||
useSSRContext,
|
||||
useShadowRoot,
|
||||
useSlots,
|
||||
useTemplateRef,
|
||||
useTransitionState,
|
||||
vModelCheckbox,
|
||||
vModelDynamic,
|
||||
vModelRadio,
|
||||
vModelSelect,
|
||||
vModelText,
|
||||
vShow,
|
||||
version,
|
||||
warn,
|
||||
watch,
|
||||
watchEffect,
|
||||
watchPostEffect,
|
||||
watchSyncEffect,
|
||||
withAsyncContext,
|
||||
withCtx,
|
||||
withDefaults,
|
||||
withDirectives,
|
||||
withKeys,
|
||||
withMemo,
|
||||
withModifiers,
|
||||
withScopeId
|
||||
} from "./chunk-PCZZZCUV.js";
|
||||
import "./chunk-5WWUZCGV.js";
|
||||
export {
|
||||
BaseTransition,
|
||||
BaseTransitionPropsValidators,
|
||||
Comment,
|
||||
DeprecationTypes,
|
||||
EffectScope,
|
||||
ErrorCodes,
|
||||
ErrorTypeStrings,
|
||||
Fragment,
|
||||
KeepAlive,
|
||||
ReactiveEffect,
|
||||
Static,
|
||||
Suspense,
|
||||
Teleport,
|
||||
Text,
|
||||
TrackOpTypes,
|
||||
Transition,
|
||||
TransitionGroup,
|
||||
TriggerOpTypes,
|
||||
VueElement,
|
||||
assertNumber,
|
||||
callWithAsyncErrorHandling,
|
||||
callWithErrorHandling,
|
||||
camelize,
|
||||
capitalize,
|
||||
cloneVNode,
|
||||
compatUtils,
|
||||
compile,
|
||||
computed,
|
||||
createApp,
|
||||
createBlock,
|
||||
createCommentVNode,
|
||||
createElementBlock,
|
||||
createBaseVNode as createElementVNode,
|
||||
createHydrationRenderer,
|
||||
createPropsRestProxy,
|
||||
createRenderer,
|
||||
createSSRApp,
|
||||
createSlots,
|
||||
createStaticVNode,
|
||||
createTextVNode,
|
||||
createVNode,
|
||||
customRef,
|
||||
defineAsyncComponent,
|
||||
defineComponent,
|
||||
defineCustomElement,
|
||||
defineEmits,
|
||||
defineExpose,
|
||||
defineModel,
|
||||
defineOptions,
|
||||
defineProps,
|
||||
defineSSRCustomElement,
|
||||
defineSlots,
|
||||
devtools,
|
||||
effect,
|
||||
effectScope,
|
||||
getCurrentInstance,
|
||||
getCurrentScope,
|
||||
getCurrentWatcher,
|
||||
getTransitionRawChildren,
|
||||
guardReactiveProps,
|
||||
h,
|
||||
handleError,
|
||||
hasInjectionContext,
|
||||
hydrate,
|
||||
hydrateOnIdle,
|
||||
hydrateOnInteraction,
|
||||
hydrateOnMediaQuery,
|
||||
hydrateOnVisible,
|
||||
initCustomFormatter,
|
||||
initDirectivesForSSR,
|
||||
inject,
|
||||
isMemoSame,
|
||||
isProxy,
|
||||
isReactive,
|
||||
isReadonly,
|
||||
isRef,
|
||||
isRuntimeOnly,
|
||||
isShallow,
|
||||
isVNode,
|
||||
markRaw,
|
||||
mergeDefaults,
|
||||
mergeModels,
|
||||
mergeProps,
|
||||
nextTick,
|
||||
normalizeClass,
|
||||
normalizeProps,
|
||||
normalizeStyle,
|
||||
onActivated,
|
||||
onBeforeMount,
|
||||
onBeforeUnmount,
|
||||
onBeforeUpdate,
|
||||
onDeactivated,
|
||||
onErrorCaptured,
|
||||
onMounted,
|
||||
onRenderTracked,
|
||||
onRenderTriggered,
|
||||
onScopeDispose,
|
||||
onServerPrefetch,
|
||||
onUnmounted,
|
||||
onUpdated,
|
||||
onWatcherCleanup,
|
||||
openBlock,
|
||||
popScopeId,
|
||||
provide,
|
||||
proxyRefs,
|
||||
pushScopeId,
|
||||
queuePostFlushCb,
|
||||
reactive,
|
||||
readonly,
|
||||
ref,
|
||||
registerRuntimeCompiler,
|
||||
render,
|
||||
renderList,
|
||||
renderSlot,
|
||||
resolveComponent,
|
||||
resolveDirective,
|
||||
resolveDynamicComponent,
|
||||
resolveFilter,
|
||||
resolveTransitionHooks,
|
||||
setBlockTracking,
|
||||
setDevtoolsHook,
|
||||
setTransitionHooks,
|
||||
shallowReactive,
|
||||
shallowReadonly,
|
||||
shallowRef,
|
||||
ssrContextKey,
|
||||
ssrUtils,
|
||||
stop,
|
||||
toDisplayString,
|
||||
toHandlerKey,
|
||||
toHandlers,
|
||||
toRaw,
|
||||
toRef,
|
||||
toRefs,
|
||||
toValue,
|
||||
transformVNodeArgs,
|
||||
triggerRef,
|
||||
unref,
|
||||
useAttrs,
|
||||
useCssModule,
|
||||
useCssVars,
|
||||
useHost,
|
||||
useId,
|
||||
useModel,
|
||||
useSSRContext,
|
||||
useShadowRoot,
|
||||
useSlots,
|
||||
useTemplateRef,
|
||||
useTransitionState,
|
||||
vModelCheckbox,
|
||||
vModelDynamic,
|
||||
vModelRadio,
|
||||
vModelSelect,
|
||||
vModelText,
|
||||
vShow,
|
||||
version,
|
||||
warn,
|
||||
watch,
|
||||
watchEffect,
|
||||
watchPostEffect,
|
||||
watchSyncEffect,
|
||||
withAsyncContext,
|
||||
withCtx,
|
||||
withDefaults,
|
||||
withDirectives,
|
||||
withKeys,
|
||||
withMemo,
|
||||
withModifiers,
|
||||
withScopeId
|
||||
};
|
||||
//# sourceMappingURL=vue.js.map
|
||||
@ -1,7 +0,0 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": [],
|
||||
"sourcesContent": [],
|
||||
"mappings": "",
|
||||
"names": []
|
||||
}
|
||||
21
README.md
21
README.md
@ -45,24 +45,33 @@ The callback URL (if running locally) should be http://localhost:5173/api/callba
|
||||
Copy and paste the Consumer Key and Consumer Secret and add it to your .env file here.
|
||||
You can use .env.example as a basis of your .env file.
|
||||
|
||||
### Testing
|
||||
|
||||
Unit tests are located in `server/test` and `src/test`.
|
||||
|
||||
##### ~~Run Unit Tests with [Vitest](https://vitest.dev/)~~
|
||||
Integration tests are located in `src/test/integration`
|
||||
|
||||
##### Run Unit Tests with [Vitest](https://vitest.dev/)
|
||||
|
||||
<strike>
|
||||
|
||||
```sh
|
||||
yarn test:unit
|
||||
npm test
|
||||
```
|
||||
</strike>
|
||||
|
||||
or
|
||||
<strike>
|
||||
##### Run Integration Tests with [Playwright](https://playwright.dev/)
|
||||
|
||||
|
||||
|
||||
```sh
|
||||
npm test:unit
|
||||
npx playwright test
|
||||
```
|
||||
</strike>
|
||||
or if you want a fancy testing UI to see what the browser is doing:
|
||||
```sh
|
||||
npx playwright test ui
|
||||
```
|
||||
|
||||
|
||||
## Compile and Minify for Production
|
||||
|
||||
|
||||
9
components.d.ts
vendored
9
components.d.ts
vendored
@ -7,13 +7,16 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ChatMessage: typeof import('./src/components/ChatMessage.vue')['default']
|
||||
ChatWidget: typeof import('./src/components/ChatWidget.vue')['default']
|
||||
ChatWidgetOld: typeof import('./src/components/ChatWidgetOld.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']
|
||||
ElAside: typeof import('element-plus/es')['ElAside']
|
||||
ElBacktop: typeof import('element-plus/es')['ElBacktop']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
||||
@ -35,6 +38,10 @@ declare module 'vue' {
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
||||
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||
ElSkeletonItem: typeof import('element-plus/es')['ElSkeletonItem']
|
||||
ElText: typeof import('element-plus/es')['ElText']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
GlossarySearchNav: typeof import('./src/components/GlossarySearchNav.vue')['default']
|
||||
HeaderNav: typeof import('./src/components/HeaderNav.vue')['default']
|
||||
@ -44,6 +51,8 @@ declare module 'vue' {
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
SearchNav: typeof import('./src/components/SearchNav.vue')['default']
|
||||
StandaloneChat: typeof import('./src/components/StandaloneChat.vue')['default']
|
||||
ToolCall: typeof import('./src/components/ToolCall.vue')['default']
|
||||
}
|
||||
export interface ComponentCustomProperties {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
|
||||
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,
|
||||
};
|
||||
14605
package-lock.json
generated
Normal file
14605
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
package.json
33
package.json
@ -2,21 +2,29 @@
|
||||
"name": "api-explorer",
|
||||
"version": "1.1.3",
|
||||
"private": true,
|
||||
"types": [
|
||||
"jest"
|
||||
],
|
||||
"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": "vitest",
|
||||
"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",
|
||||
"format": "prettier --write src/ server/"
|
||||
"format": "prettier --write src/ server/",
|
||||
"test:integration": "vitest run --config vitest.integration.config.js",
|
||||
"test:integration:watch": "vitest --config vitest.integration.config.js",
|
||||
"test:integration:ui": "vitest --ui --config vitest.integration.config.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.1.0",
|
||||
"@fontsource/roboto": "^5.0.0",
|
||||
"@highlightjs/vue-plugin": "^2.1.0",
|
||||
"@types/node-fetch": "^2.6.12",
|
||||
"ai": "^4.1.43",
|
||||
"axios": "^1.7.4",
|
||||
"cheerio": "^1.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
@ -29,8 +37,11 @@
|
||||
"highlight.js": "^11.8.0",
|
||||
"json-editor-vue": "^0.17.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"langchain": "^0.3.19",
|
||||
"markdown-it": "^14.1.0",
|
||||
"node-fetch": "^2.6.7",
|
||||
"oauth": "^0.10.0",
|
||||
"obp-api-typescript": "^1.0.1",
|
||||
"obp-typescript": "^1.0.36",
|
||||
"pinia": "^2.0.37",
|
||||
"prismjs": "^1.29.0",
|
||||
@ -44,16 +55,23 @@
|
||||
"vanilla-jsoneditor": "^2.3.3",
|
||||
"vue": "^3.5.1",
|
||||
"vue-i18n": "^9.4.0",
|
||||
"vue-json-pretty": "^2.4.0",
|
||||
"vue-router": "^4.2.2",
|
||||
"vue-socket.io": "^3.0.10",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ai-sdk/vue": "^1.1.18",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"@rushstack/eslint-patch": "^1.4.0",
|
||||
"@testing-library/vue": "^8.1.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/markdown-it": "^14.1.1",
|
||||
"@types/node": "^20.5.7",
|
||||
"@types/oauth": "^0.9.6",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@vitejs/plugin-vue": "^4.6.2",
|
||||
"@vitejs/plugin-vue-jsx": "^3.1.0",
|
||||
"@vue/eslint-config-prettier": "^9.0.0",
|
||||
@ -62,10 +80,16 @@
|
||||
"@vue/tsconfig": "^0.1.3",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-plugin-vue": "^9.12.0",
|
||||
"happy-dom": "^17.1.4",
|
||||
"jest": "^29.7.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"node-mocks-http": "^1.16.2",
|
||||
"npm-run-all2": "^7.0.1",
|
||||
"playwright": "^1.51.1",
|
||||
"prettier": "^3.0.1",
|
||||
"superagent": "^9.0.0",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "~5.2.2",
|
||||
"unplugin-auto-import": "^0.18.0",
|
||||
@ -74,7 +98,10 @@
|
||||
"vite": "^4.4.0",
|
||||
"vite-plugin-node-polyfills": "^0.10.0",
|
||||
"vite-plugin-rewrite-all": "^1.0.2",
|
||||
"vitest": "^0.34.0",
|
||||
"vitest": "^0.34.6",
|
||||
"vue-tsc": "^2.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@langchain/core": "0.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
51
playwright.config.ts
Normal file
51
playwright.config.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
// Read from ".env" file.
|
||||
dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
|
||||
export default defineConfig({
|
||||
// Look for test files in the "tests" directory, relative to this configuration file.
|
||||
testDir: 'src/test/integration',
|
||||
|
||||
globalSetup: require.resolve('./src/test/integration/global.setup.ts'),
|
||||
|
||||
globalTeardown: require.resolve('./src/test/integration/global.teardown.ts'),
|
||||
// Reporter to use
|
||||
reporter: 'html',
|
||||
|
||||
use: {
|
||||
// Base URL to use in actions like `await page.goto('/')`.
|
||||
baseURL: process.env.VITE_OBP_API_EXPLORER_HOST,
|
||||
|
||||
// Collect trace when retrying the failed test.
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
// Configure projects for major browsers.
|
||||
projects: [
|
||||
{
|
||||
name: 'setup',
|
||||
testMatch: /.*\.setup\.ts/,
|
||||
teardown: 'teardown',
|
||||
},
|
||||
{
|
||||
name: 'teardown',
|
||||
testMatch: /.*\.teardown\.ts/
|
||||
},
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
storageState: 'src/test/integration/playwright/.auth/user.json',
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
],
|
||||
// Run your local dev server before starting the tests.
|
||||
webServer: {
|
||||
command: 'vite',
|
||||
url: process.env.VITE_OBP_API_EXPLORER_HOST,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -1,124 +0,0 @@
|
||||
/*
|
||||
* Open Bank Project - API Explorer II
|
||||
* Copyright (C) 2023-2024, TESOBE GmbH
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* Email: contact@tesobe.com
|
||||
* TESOBE GmbH
|
||||
* Osloerstrasse 16/17
|
||||
* Berlin 13359, Germany
|
||||
*
|
||||
* This product includes software developed at
|
||||
* TESOBE (http://www.tesobe.com/)
|
||||
*
|
||||
*/
|
||||
|
||||
import { Controller, Session, Req, Res, Post } from 'routing-controllers'
|
||||
import { Request, Response } from 'express'
|
||||
import OBPClientService from '../services/OBPClientService'
|
||||
import { Service } from 'typedi'
|
||||
import * as fs from 'fs'
|
||||
import * as jwt from 'jsonwebtoken'
|
||||
|
||||
@Service()
|
||||
@Controller('/opey')
|
||||
/**
|
||||
* Controller class for handling Opey related operations.
|
||||
* This used to hold the /chat endpoint, but that endpoint has become obsolete since using websockets.
|
||||
* Now it serves to get tokens to authenticate the user at websocket handshake.
|
||||
* This is called from the frontend when ChatWidget.vue is mounted. (It is done at the backend to keep the private key secret)
|
||||
*/
|
||||
export class OpeyController {
|
||||
constructor(
|
||||
private obpClientService: OBPClientService,
|
||||
) {}
|
||||
|
||||
@Post('/token')
|
||||
/**
|
||||
* Retrieves a JWT token for the current user.
|
||||
* This only works if the user is logged in. (i.e. the user has a valid session)
|
||||
* Request for the token is made to POST /api/opey/token
|
||||
*
|
||||
* @param session - The session object.
|
||||
* @param request - The request object.
|
||||
* @param response - The response object.
|
||||
* @returns The response containing the JWT token or an error message.
|
||||
*
|
||||
*/
|
||||
async getToken(
|
||||
@Session() session: any,
|
||||
@Req() request: Request,
|
||||
@Res() response: Response
|
||||
): Response {
|
||||
try {
|
||||
// Get current user
|
||||
const oauthConfig = session['clientConfig']
|
||||
const version = this.obpClientService.getOBPVersion()
|
||||
const currentUser = await this.obpClientService.get(`/obp/${version}/users/current`, oauthConfig)
|
||||
const currentResponseKeys = Object.keys(currentUser)
|
||||
// If current user is logged in, issue JWT signed with private key
|
||||
if (currentResponseKeys.includes('user_id')) {
|
||||
// sign
|
||||
const jwtToken = this.generateJWT(currentUser.user_id, currentUser.username, session)
|
||||
return response.status(200).json({ token: jwtToken });
|
||||
} else {
|
||||
return response.status(400).json({ message: 'User not logged in, Authentication required' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in token endpoint: ", error);
|
||||
return response.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a JSON Web Token (JWT) for the given Open Bank Project (OBP) user.
|
||||
* @param obpUserId - The ID of the OBP user.
|
||||
* @param obpUsername - The username of the OBP user.
|
||||
* @param session - The session object.
|
||||
* @returns The generated JWT.
|
||||
*/
|
||||
generateJWT(obpUserId: string, obpUsername: string, session: typeof Session): string {
|
||||
|
||||
// Retrieve secret key
|
||||
let privateKey: string;
|
||||
if (session['opeyToken']) {
|
||||
console.log("Returning cached token");
|
||||
return session['opeyToken'];
|
||||
}
|
||||
|
||||
// Read private key from file
|
||||
// Private key must be in the server/cert directory, this is pretty janky at the moment and should be improved
|
||||
// Opey must also have a copy of the public key to verify the JWT
|
||||
try {
|
||||
privateKey = fs.readFileSync('./server/cert/private_key.pem', {encoding: 'utf-8'});
|
||||
} catch (error) {
|
||||
console.error("Error reading private key: ", error);
|
||||
return '';
|
||||
}
|
||||
|
||||
// Allows some user data to be passed in the JWT (this could be the obp consent in the future)
|
||||
const payload = {
|
||||
user_id: obpUserId,
|
||||
username: obpUsername,
|
||||
exp: Math.floor(Date.now() / 1000) + (60 * 60),
|
||||
};
|
||||
|
||||
console.log("Generating new token for Opey");
|
||||
const token = jwt.sign(payload, privateKey, { algorithm: 'RS256' });
|
||||
session['opeyToken'] = token;
|
||||
|
||||
return token
|
||||
}
|
||||
}
|
||||
371
server/controllers/OpeyIIController.ts
Normal file
371
server/controllers/OpeyIIController.ts
Normal file
@ -0,0 +1,371 @@
|
||||
import { Controller, Session, Req, Res, Post, Get } from 'routing-controllers'
|
||||
import { Request, Response} from 'express'
|
||||
import { Readable } from "node:stream"
|
||||
import { ReadableStream as WebReadableStream } from "stream/web"
|
||||
import { Service } from 'typedi'
|
||||
import OBPClientService from '../services/OBPClientService'
|
||||
import OpeyClientService from '../services/OpeyClientService'
|
||||
import OBPConsentsService from '../services/OBPConsentsService'
|
||||
|
||||
import { UserInput, OpeyConfig} from '../schema/OpeySchema'
|
||||
import { APIApi, Configuration, ConsentApi, ConsumerConsentrequestsBody, InlineResponse20151 } from 'obp-api-typescript'
|
||||
|
||||
@Service()
|
||||
@Controller('/opey')
|
||||
|
||||
export class OpeyController {
|
||||
constructor(
|
||||
public obpClientService: OBPClientService,
|
||||
public opeyClientService: OpeyClientService,
|
||||
public obpConsentsService: OBPConsentsService
|
||||
) {}
|
||||
|
||||
@Get('/')
|
||||
async getStatus(
|
||||
@Res() response: Response
|
||||
): Promise<Response | any> {
|
||||
|
||||
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,
|
||||
): Promise<Response> {
|
||||
|
||||
if (!session) {
|
||||
console.error("Session not found")
|
||||
return response.status(401).json({ error: 'Session Time Out' })
|
||||
}
|
||||
// Check if the consent is in the session, and can be added to the headers
|
||||
const opeyConfig = session['opeyConfig']
|
||||
if (!opeyConfig) {
|
||||
console.error("Opey config not found in session")
|
||||
return response.status(500).json({ error: 'Internal Server Error' })
|
||||
}
|
||||
|
||||
// Read user input from request body
|
||||
let user_input: UserInput
|
||||
try {
|
||||
console.log("Request body: ", request.body)
|
||||
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' })
|
||||
}
|
||||
|
||||
|
||||
// Transform to decode and log the stream
|
||||
const frontendTransformer = new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
// Decode the chunk to a string
|
||||
const decodedChunk = new TextDecoder().decode(chunk)
|
||||
|
||||
console.log("Sending chunk", decodedChunk)
|
||||
controller.enqueue(decodedChunk);
|
||||
},
|
||||
flush(controller) {
|
||||
console.log('[flush]');
|
||||
// Close ReadableStream when done
|
||||
controller.terminate();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
let stream: ReadableStream | null = null
|
||||
|
||||
try {
|
||||
// Read web stream from OpeyClientService
|
||||
console.log("Calling OpeyClientService.stream")
|
||||
stream = await this.opeyClientService.stream(user_input, opeyConfig)
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error reading stream: ", error)
|
||||
return response.status(500).json({ error: 'Internal Server Error' })
|
||||
}
|
||||
|
||||
if (!stream) {
|
||||
console.error("Stream is not recieved or not readable")
|
||||
return response.status(500).json({ error: 'Internal Server Error' })
|
||||
}
|
||||
|
||||
|
||||
// Transform our stream if needed, right now this is just a passthrough
|
||||
const frontendStream: ReadableStream = stream.pipeThrough(frontendTransformer)
|
||||
|
||||
// If we need to split the stream into two, we can use the tee method as below
|
||||
|
||||
// const streamTee = langchainStream.tee()
|
||||
// if (!streamTee) {
|
||||
// console.error("Stream is not tee'd")
|
||||
// return response.status(500).json({ error: 'Internal Server Error' })
|
||||
// }
|
||||
// const [stream1, stream2] = streamTee
|
||||
|
||||
// function to convert a web stream to a node stream
|
||||
const safeFromWeb = (webStream: WebReadableStream<any>): Readable => {
|
||||
if (typeof Readable.fromWeb === 'function') {
|
||||
return Readable.fromWeb(webStream)
|
||||
} else {
|
||||
console.warn('Readable.fromWeb is not available, using a polyfill');
|
||||
|
||||
// Create a Node.js Readable stream
|
||||
const nodeReadable = new Readable({
|
||||
read() {}
|
||||
});
|
||||
|
||||
// Pump data from webreadable to node readable stream
|
||||
const reader = webStream.getReader();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const {done, value} = await reader.read();
|
||||
|
||||
if (done) {
|
||||
nodeReadable.push(null); // end stream
|
||||
break;
|
||||
}
|
||||
|
||||
nodeReadable.push(value);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading from web stream:', error);
|
||||
nodeReadable.destroy(error instanceof Error ? error : new Error(error));
|
||||
}
|
||||
})();
|
||||
|
||||
return nodeReadable
|
||||
}
|
||||
}
|
||||
|
||||
const nodeStream = safeFromWeb(frontendStream as WebReadableStream<any>)
|
||||
|
||||
response.setHeader('Content-Type', 'text/event-stream');
|
||||
response.setHeader('Cache-Control', 'no-cache');
|
||||
response.setHeader('Connection', 'keep-alive');
|
||||
|
||||
nodeStream.pipe(response);
|
||||
|
||||
|
||||
return new Promise<Response>((resolve, reject) => {
|
||||
nodeStream.on('end', () => {
|
||||
resolve(response);
|
||||
});
|
||||
nodeStream.on('error', (error) => {
|
||||
console.error('Stream error:', error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
// Add a timeout to prevent hanging promises
|
||||
const timeout = setTimeout(() => {
|
||||
console.warn('Stream timeout reached');
|
||||
resolve(response);
|
||||
}, 30000);
|
||||
|
||||
// Clear the timeout when stream ends
|
||||
nodeStream.on('end', () => clearTimeout(timeout));
|
||||
nodeStream.on('error', () => clearTimeout(timeout));
|
||||
})
|
||||
|
||||
|
||||
}
|
||||
|
||||
@Post('/invoke')
|
||||
async invokeOpey(
|
||||
@Session() session: any,
|
||||
@Req() request: Request,
|
||||
@Res() response: Response
|
||||
): Promise<Response | any> {
|
||||
|
||||
|
||||
// Check if the consent is in the session, and can be added to the headers
|
||||
const opeyConfig = session['opeyConfig']
|
||||
if (!opeyConfig) {
|
||||
console.error("Opey config not found in session")
|
||||
return response.status(500).json({ error: 'Internal Server Error' })
|
||||
}
|
||||
|
||||
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 invoke 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, opeyConfig)
|
||||
|
||||
//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/request')
|
||||
// /**
|
||||
// * Retrieves a consent request from OBP
|
||||
// *
|
||||
// */
|
||||
// async getConsentRequest(
|
||||
// @Session() session: any,
|
||||
// @Req() request: Request,
|
||||
// @Res() response: Response,
|
||||
// ): Promise<Response | any> {
|
||||
// try {
|
||||
|
||||
// let obpToken: string
|
||||
|
||||
// obpToken = await this.obpClientService.getDirectLoginToken()
|
||||
// console.log("Got token: ", obpToken)
|
||||
// const authHeader = `DirectLogin token="${obpToken}"`
|
||||
// console.log("Auth header: ", authHeader)
|
||||
|
||||
// //const obpOAuthHeaders = await this.obpClientService.getOAuthHeader('/consents', 'POST')
|
||||
// //console.log("OBP OAuth Headers: ", obpOAuthHeaders)
|
||||
|
||||
// const obpConfig: Configuration = {
|
||||
// apiKey: authHeader,
|
||||
// basePath: process.env.VITE_OBP_API_HOST,
|
||||
// }
|
||||
|
||||
// console.log("OBP Config: ", obpConfig)
|
||||
|
||||
// const consentAPI = new ConsentApi(obpConfig, process.env.VITE_OBP_API_HOST)
|
||||
|
||||
|
||||
// // OBP sdk naming is a bit mad, can be rectified in the future
|
||||
// const consentRequestResponse = await consentAPI.oBPv500CreateConsentRequest({
|
||||
// accountAccess: [],
|
||||
// everything: false,
|
||||
// entitlements: [],
|
||||
// consumerId: '',
|
||||
// } as unknown as ConsumerConsentrequestsBody,
|
||||
// {
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// },
|
||||
// }
|
||||
// )
|
||||
|
||||
// //console.log("Consent request response: ", consentRequestResponse)
|
||||
|
||||
// console.log({consentId: consentRequestResponse.data.consent_request_id})
|
||||
// session['obpConsentRequestId'] = consentRequestResponse.data.consent_request_id
|
||||
|
||||
// return response.status(200).json(JSON.stringify({consentId: consentRequestResponse.data.consent_request_id}))
|
||||
// //console.log(await response.body.json())
|
||||
|
||||
|
||||
// } catch (error) {
|
||||
// console.error("Error in consent/request endpoint: ", 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
|
||||
): Promise<Response | any> {
|
||||
try {
|
||||
// create consent as logged in user
|
||||
const opeyConfig = await this.opeyClientService.getOpeyConfig()
|
||||
session['opeyConfig'] = opeyConfig
|
||||
|
||||
// Check if user already has a consent for opey
|
||||
// If so, return the consent id
|
||||
const consentId = await this.obpConsentsService.getExistingOpeyConsentId(session)
|
||||
|
||||
if (consentId) {
|
||||
console.log("Existing consent ID: ", consentId)
|
||||
// If we have a consent id, we can get the consent from OBP
|
||||
const consent = await this.obpConsentsService.getConsentByConsentId(session, consentId)
|
||||
|
||||
return response.status(200).json({consent_id: consent.consent_id, jwt: consent.jwt});
|
||||
} else {
|
||||
console.log("No existing consent ID found")
|
||||
}
|
||||
// Either here or in this method, we should check if there is already a consent stored in the session
|
||||
|
||||
await this.obpConsentsService.createConsent(session)
|
||||
|
||||
console.log("Consent at controller: ", session['opeyConfig'])
|
||||
|
||||
const authConfig = session['opeyConfig']['authConfig']
|
||||
|
||||
return response.status(200).json({consent_id: authConfig?.obpConsent.consent_id, jwt: authConfig?.obpConsent.jwt});
|
||||
|
||||
} 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
|
||||
// ): Promise<Response | any> {
|
||||
// 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' });
|
||||
// }
|
||||
|
||||
// }
|
||||
|
||||
|
||||
}
|
||||
36
server/schema/OpeySchema.ts
Normal file
36
server/schema/OpeySchema.ts
Normal file
@ -0,0 +1,36 @@
|
||||
|
||||
export interface UserInput {
|
||||
message: string;
|
||||
thread_id?: string | null;
|
||||
is_tool_call_approval: boolean;
|
||||
}
|
||||
|
||||
export interface StreamInput extends UserInput {
|
||||
stream_tokens: boolean;
|
||||
}
|
||||
|
||||
export interface OpeyPaths {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface OBPConsent {
|
||||
consent_id: string;
|
||||
jwt: string;
|
||||
status: string;
|
||||
}
|
||||
export interface AuthConfig {
|
||||
obpConsent: OBPConsent;
|
||||
// Add more auth config fields here if needed
|
||||
}
|
||||
|
||||
export interface OpeyConfig {
|
||||
baseUri: string,
|
||||
paths: OpeyPaths,
|
||||
authConfig?: AuthConfig,
|
||||
}
|
||||
|
||||
export interface ConsentRequestResponse {
|
||||
consentId: string;
|
||||
}
|
||||
@ -37,11 +37,27 @@ import {
|
||||
CreateAny,
|
||||
UpdateAny,
|
||||
DiscardAny,
|
||||
Any
|
||||
Any,
|
||||
} from 'obp-typescript'
|
||||
import type { APIClientConfig, OAuthConfig } from 'obp-typescript'
|
||||
import { OAuth } from 'obp-typescript'
|
||||
|
||||
@Service()
|
||||
/**
|
||||
* OBPClientService provides methods for interacting with the Open Bank Project API.
|
||||
*
|
||||
* This service handles API communication with OBP, including OAuth authentication,
|
||||
* making HTTP requests (GET, POST, PUT, DELETE), and managing API configurations.
|
||||
*
|
||||
* @class OBPClientService
|
||||
*
|
||||
* @property {OAuthConfig} oauthConfig - OAuth configuration for authentication
|
||||
* @property {APIClientConfig} clientConfig - API client configuration
|
||||
*
|
||||
* @example
|
||||
* const obpService = new OBPClientService();
|
||||
* const response = await obpService.get('/banks', clientConfig);
|
||||
*/
|
||||
export default class OBPClientService {
|
||||
private oauthConfig: OAuthConfig
|
||||
private clientConfig: APIClientConfig
|
||||
@ -56,6 +72,7 @@ export default class OBPClientService {
|
||||
version: process.env.VITE_OBP_API_VERSION as Version,
|
||||
oauthConfig: this.oauthConfig
|
||||
}
|
||||
|
||||
}
|
||||
async get(path: string, clientConfig: any): Promise<any> {
|
||||
const config = this.getSessionConfig(clientConfig)
|
||||
@ -73,7 +90,7 @@ export default class OBPClientService {
|
||||
const config = this.getSessionConfig(clientConfig)
|
||||
return await discard<API.Any>(config, Any)(DiscardAny)(path)
|
||||
}
|
||||
private getSessionConfig(clientConfig: any): any {
|
||||
private getSessionConfig(clientConfig: APIClientConfig): APIClientConfig {
|
||||
return clientConfig || this.clientConfig
|
||||
}
|
||||
|
||||
@ -81,7 +98,72 @@ export default class OBPClientService {
|
||||
return this.clientConfig.version
|
||||
}
|
||||
|
||||
getOBPClientConfig(): any {
|
||||
getOBPClientConfig(): APIClientConfig {
|
||||
return this.clientConfig
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Generates an OAuth1 authentication header for a given API request. I.e. to use in the Authorization header.
|
||||
* Currently used for boostrapping the newer 'obp-api-typescript' SDK.
|
||||
*
|
||||
* @param path - The API endpoint path to access i.e. /banks or /consents/IMPLICIT
|
||||
* NOTE: the path should not include the baseUri
|
||||
* @param method - The HTTP method to use (GET, POST, PUT, DELETE, etc.)
|
||||
* @param clientConfig - Configuration object containing the user's session data
|
||||
* @returns A Promise resolving to the OAuth authentication header string
|
||||
* @throws Error if OAuth configuration is missing or if access token is not available
|
||||
*
|
||||
* @remarks
|
||||
* This method requires that the user has already authenticated and the OAuth access token
|
||||
* is stored in the clientConfig. It uses OAuth1 for authentication, which may be replaced
|
||||
* with OAuth2 in future implementations.
|
||||
*/
|
||||
async getOAuthHeader(path: string, method:string, clientConfig: any): Promise<string> {
|
||||
// This gets the OAuth1 header for the given path and method for the logged in user
|
||||
// We should probably transition to OAuth2
|
||||
console.log('Getting OAuth header for path:', path, 'method:', method)
|
||||
// OAuth1 access token stored in the clientConfig
|
||||
const config = this.getSessionConfig(clientConfig)
|
||||
if (!config.oauthConfig) {
|
||||
throw new Error('OAuth configuration is missing')
|
||||
}
|
||||
if(!config.oauthConfig.accessToken) {
|
||||
throw new Error('Access token is missing, OAuth headers trying to be retrieved before login')
|
||||
}
|
||||
|
||||
const oauthInstance = new OAuth(config.oauthConfig).get()
|
||||
|
||||
// Use the OAuth1 instance to get the header
|
||||
const url = `${config.baseUri}${path}`
|
||||
const authHeader = oauthInstance.authHeader(url, config.oauthConfig.accessToken.key, config.oauthConfig.accessToken.secret, method)
|
||||
return authHeader
|
||||
}
|
||||
|
||||
async getDirectLoginToken(): Promise<string> {
|
||||
// Hilariously insecure, should be replaced with an OAuth 2 flow as soon as possible
|
||||
|
||||
const consumerKey = this.oauthConfig.consumerKey
|
||||
const username = process.env.VITE_OBP_DIRECT_LOGIN_USERNAME
|
||||
const password = process.env.VITE_OBP_DIRECT_LOGIN_PASSWORD
|
||||
|
||||
const authHeader = `DirectLogin username="${username}",password="${password}",consumer_key="${consumerKey}"`
|
||||
// Get token from OBP
|
||||
const tokenResponse = await fetch(`${this.clientConfig.baseUri}/my/logins/direct`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': authHeader
|
||||
}
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
throw new Error(`Failed to get direct login token: ${tokenResponse.statusText} ${await tokenResponse.text()}`)
|
||||
}
|
||||
|
||||
const token = await tokenResponse.json()
|
||||
return token.token
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
292
server/services/OBPConsentsService.ts
Normal file
292
server/services/OBPConsentsService.ts
Normal file
@ -0,0 +1,292 @@
|
||||
import { Service } from 'typedi'
|
||||
import { Configuration, ConsentApi, ConsentsIMPLICITBody1, ConsumerConsentrequestsBody, InlineResponse20151, InlineResponse2017, ErrorUserNotLoggedIn} from 'obp-api-typescript'
|
||||
import OBPClientService from './OBPClientService'
|
||||
import OauthInjectedService from './OauthInjectedService'
|
||||
import { AxiosResponse } from 'axios'
|
||||
import axios from 'axios'
|
||||
import { Session } from 'express-session'
|
||||
|
||||
@Service()
|
||||
/**
|
||||
* Service for managing Open Banking Project (OBP) consents functionality.
|
||||
* This class handles the creation of consent clients, consent creation, and retrieval
|
||||
* based on user sessions.
|
||||
*
|
||||
* @class OBPConsentsService
|
||||
* @description Provides methods to interact with OBP Consent APIs, allowing the application
|
||||
* to create and manage consents that permit access to user accounts via API Explorer II.
|
||||
*
|
||||
* Key functionalities:
|
||||
* - Creating consent API clients based on user sessions
|
||||
* - Creating implicit consents for access delegation i.e. for opey
|
||||
* - Retrieving existing consents by ID
|
||||
* - Finding consents associated with specific consumers (e.g., Opey)
|
||||
*
|
||||
* @requires OBPClientService
|
||||
* @requires Configuration
|
||||
* @requires ConsentApi
|
||||
* @requires InlineResponse2017
|
||||
* @requires ConsentsIMPLICITBody1
|
||||
* @requires axios
|
||||
*/
|
||||
export default class OBPConsentsService {
|
||||
private consentApiConfig: Configuration
|
||||
public obpClientService: OBPClientService // This needs to be changed once we migrate away from the old OBP SDK
|
||||
constructor() {
|
||||
this.obpClientService = new OBPClientService()
|
||||
}
|
||||
/**
|
||||
* Function to create a OBP Consents API client
|
||||
* at differnt times in the consent flow we will either need to be acting as the logged in user, or the API Explorer II consumer
|
||||
*
|
||||
* @param path
|
||||
* @param method
|
||||
* @param as_client
|
||||
* @returns
|
||||
*/
|
||||
async createUserConsentsClient(session: any, path: string, method: string): Promise<ConsentApi | undefined> {
|
||||
// This function creates a Consents API client as the logged in user, using their OAuth1 headers
|
||||
|
||||
// Check if the user is logged in
|
||||
const clientConfig = session['clientConfig']
|
||||
if (!clientConfig || !clientConfig.oauthConfig.accessToken) {
|
||||
throw new Error('User is not logged in')
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
|
||||
// Get the OAuth1 headers for the logged in user to use in the API call
|
||||
const oauth1Headers = await this.obpClientService.getOAuthHeader(path, method, clientConfig)
|
||||
|
||||
// Set config for the Consents API client from the new typescript SDK
|
||||
this.consentApiConfig = new Configuration({
|
||||
basePath: this.obpClientService.getOBPClientConfig().baseUri,
|
||||
apiKey: oauth1Headers
|
||||
})
|
||||
|
||||
// Create the Consents API client
|
||||
return new ConsentApi(this.consentApiConfig)
|
||||
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
throw new Error(`Could not create Consents API client for logged in user, ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
async createConsent(session: Session): Promise<InlineResponse2017 | undefined> {
|
||||
// Create a consent as the logged in user, using Opey's consumerID
|
||||
// I.e. give permission to Opey to do anything on behalf of the logged in user
|
||||
|
||||
// Get the Consents API client from the OBP SDK
|
||||
const client = await this.createUserConsentsClient(session, '/obp/v5.1.0/my/consents/IMPLICIT', 'POST')
|
||||
if (!client) {
|
||||
throw new Error('Could not create Consents API client')
|
||||
}
|
||||
|
||||
// get consumer ID for Opey
|
||||
const opeyConsumerID = process.env.VITE_OPEY_CONSUMER_ID
|
||||
if (!opeyConsumerID) {
|
||||
throw new Error('Opey Consumer ID is missing, please set VITE_OPEY_CONSUMER_ID')
|
||||
}
|
||||
|
||||
// Format date for OBP, this is a mess
|
||||
const today = new Date().toISOString().split('.')[0] + 'Z' // get rid of milliseconds as OBP doesn't like them;
|
||||
|
||||
const body: ConsentsIMPLICITBody1 = {
|
||||
everything: true,
|
||||
entitlements: [],
|
||||
consumer_id: opeyConsumerID,
|
||||
views: [],
|
||||
valid_from: today,
|
||||
time_to_live: 3600,
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const consentResponse = await client.oBPv510CreateConsentImplicit(body, {headers: {'Content-Type': 'application/json',}})
|
||||
|
||||
// Save the consent in the session
|
||||
session['opeyConfig'] = {
|
||||
authConfig: {
|
||||
obpConsent: consentResponse.data
|
||||
}
|
||||
}
|
||||
|
||||
return consentResponse.data
|
||||
|
||||
} catch (error: any) {
|
||||
console.log('error', error)
|
||||
if (error.response && error.response.data) {
|
||||
const errorData = error.response.data
|
||||
if (errorData.message) {
|
||||
throw new Error(`OBP Error: ${JSON.stringify(errorData)}`);
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not create consent, ${error}`)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Retrieves a consent by consent ID for the current user.
|
||||
*
|
||||
* This method fetches a specific consent using its ID and updates the session
|
||||
* with the retrieved consent data under the opeyConfig property.
|
||||
*
|
||||
* @param session - The user's session object, which must contain clientConfig with valid OAuth tokens
|
||||
* @param consentId - The unique identifier of the consent to retrieve
|
||||
* @returns Promise resolving to the consent data retrieved from OBP API
|
||||
* @throws Error if the user is not logged in (no valid clientConfig or accessToken)
|
||||
* @throws Error if the request to get the consent fails
|
||||
*/
|
||||
async getConsentByConsentId(session: Session, consentId: string): Promise<any> {
|
||||
|
||||
const clientConfig = session['clientConfig']
|
||||
if (!clientConfig || !clientConfig.oauthConfig.accessToken) {
|
||||
throw new Error('User is not logged in')
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this._sendOBPRequest(`/obp/v5.1.0/user/current/consents/${consentId}`, 'GET', clientConfig)
|
||||
|
||||
session['opeyConfig'] = {
|
||||
authConfig: {
|
||||
obpConsent: response.data
|
||||
}
|
||||
}
|
||||
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
throw new Error(`Consent with ID ${consentId} not retrieved: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
async checkConsentExpired(consent: any): Promise<boolean> { //DEBUG
|
||||
// Check if the consent is expired
|
||||
// Decode the JWT and check the exp field
|
||||
|
||||
const exp = consent.jwt_payload.exp
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
return exp < now
|
||||
}
|
||||
|
||||
async getExistingOpeyConsentId(session: Session): Promise<any> {
|
||||
// Get Consents for the current user, check if any of them are for Opey
|
||||
// If so, return the consent
|
||||
|
||||
// I.e. this is done by iterating and finding the consent with the correct consumer ID
|
||||
|
||||
// Get the Consents API client from the OBP SDK
|
||||
// The OBP SDK is messed up here, so we'll need to use Fetch until the SWAGGER WILL ACTUALLY WORK
|
||||
// const client = await this.createUserConsentsClient(session, '/obp/v5.1.0/my/consents/IMPLICIT', 'POST')
|
||||
// if (!client) {
|
||||
// throw new Error('Could not create Consents API client')
|
||||
// }
|
||||
|
||||
|
||||
// Function to send an OBP request using the logged in user's OAuth1 headers
|
||||
|
||||
const clientConfig = session['clientConfig']
|
||||
if (!clientConfig || !clientConfig.oauthConfig.accessToken) {
|
||||
throw new Error('User is not logged in')
|
||||
}
|
||||
|
||||
// We need to change this back to consent infos once OBP shows 'EXPIRED' in the status
|
||||
// Right now we have to check the JWT ourselves
|
||||
const consentInfosPath = '/obp/v5.1.0/my/consents'
|
||||
//const consentInfosPath = '/obp/v5.1.0/my/consent-infos'
|
||||
|
||||
let opeyConsentId: string | null = null
|
||||
try {
|
||||
const response = await this._sendOBPRequest(consentInfosPath, 'GET', clientConfig)
|
||||
const consents = response.data.consents
|
||||
|
||||
const opeyConsumerID = process.env.VITE_OPEY_CONSUMER_ID
|
||||
if (!opeyConsumerID) {
|
||||
throw new Error('Opey Consumer ID is missing, please set VITE_OPEY_CONSUMER_ID')
|
||||
}
|
||||
|
||||
for (const consent of consents) {
|
||||
console.log(`consent_consumer_id: ${consent.consumer_id}, opey_consumer_id: ${opeyConsumerID}\n consent_status: ${consent.status}`) //DEBUG
|
||||
if (consent.consumer_id === opeyConsumerID && consent.status === 'ACCEPTED') {
|
||||
// Check if the consent is expired
|
||||
const isExpired = await this.checkConsentExpired(consent)
|
||||
if (isExpired) {
|
||||
console.log('getExistingConsent: Consent is expired')
|
||||
continue
|
||||
}
|
||||
opeyConsentId = consent.consent_id
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!opeyConsentId) {
|
||||
console.log('getExistingConsent: No consent found for Opey for current user')
|
||||
return null
|
||||
} else {
|
||||
return opeyConsentId
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
throw new Error(`Could not get existing consent info, ${error}`)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async _sendOBPRequest (path: string, method: string, clientConfig: any) {
|
||||
const oauth1Headers = await this.obpClientService.getOAuthHeader(path, method, clientConfig)
|
||||
const config = {
|
||||
headers: {
|
||||
'Authorization': oauth1Headers,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
}
|
||||
return axios.get(`${clientConfig.baseUri}${path}`, config)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// Probably not needed, but will keep for later
|
||||
|
||||
// async createConsentRequest(): Promise<InlineResponse20151 | undefined> {
|
||||
// // this should be done as API Explorer II, so set client on instance for that
|
||||
// const client = await this.createConsentClient('API_Explorer')
|
||||
// if (!client) {
|
||||
// throw new Error('Could not create Consents API client')
|
||||
// }
|
||||
// // Create a consent request
|
||||
// // Parameters in body to be changed later to fit our needs, or match parameters given to this function
|
||||
// try {
|
||||
// const consentRequestResponse = await client.oBPv500CreateConsentRequest(
|
||||
// {
|
||||
// accountAccess: [],
|
||||
// everything: false,
|
||||
// entitlements: [],
|
||||
// consumerId: '',
|
||||
// } as unknown as ConsumerConsentrequestsBody,
|
||||
// {
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// },
|
||||
// }
|
||||
// )
|
||||
|
||||
// return consentRequestResponse.data
|
||||
// } catch (error) {
|
||||
// console.error(error)
|
||||
// throw new Error(`Could not create consent request, ${error}`)
|
||||
// }
|
||||
|
||||
|
||||
|
||||
// }
|
||||
}
|
||||
254
server/services/OpeyClientService.ts
Normal file
254
server/services/OpeyClientService.ts
Normal file
@ -0,0 +1,254 @@
|
||||
import { Service } from 'typedi'
|
||||
import { UserInput, StreamInput, OpeyConfig, ConsentRequestResponse } from '../schema/OpeySchema'
|
||||
import OBPClientService from './OBPClientService'
|
||||
|
||||
@Service()
|
||||
export default class OpeyClientService {
|
||||
private opeyConfig: OpeyConfig
|
||||
public obpClientService: OBPClientService
|
||||
constructor() {
|
||||
this.opeyConfig = {
|
||||
baseUri: process.env.VITE_CHATBOT_URL? process.env.VITE_CHATBOT_URL : 'http://localhost:5000',
|
||||
paths: {
|
||||
status: '/status',
|
||||
stream: '/stream',
|
||||
invoke: '/invoke',
|
||||
approve_tool: '/approve_tool/{thead_id}',
|
||||
feedback: '/feedback',
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
/**
|
||||
* Either sets the Opey configuration or returns the current configuration.
|
||||
* If a partial config is provided, it will be merged with the current config,
|
||||
* only overwriting explicitly defined fields.
|
||||
*
|
||||
* @param partialConfig - Optional partial configuration to merge with default config
|
||||
* @returns Complete OpeyConfig with merged values
|
||||
*/
|
||||
async getOpeyConfig(partialConfig?: Partial<OpeyConfig>): Promise<OpeyConfig> {
|
||||
if (!partialConfig) {
|
||||
return this.opeyConfig;
|
||||
}
|
||||
|
||||
// Create a deep copy of the current config to avoid mutation
|
||||
const mergedConfig = JSON.parse(JSON.stringify(this.opeyConfig));
|
||||
|
||||
// Merge the base URI if provided
|
||||
if (partialConfig.baseUri) {
|
||||
mergedConfig.baseUri = partialConfig.baseUri;
|
||||
}
|
||||
|
||||
// Merge paths if provided (only overwrite defined paths)
|
||||
if (partialConfig.paths) {
|
||||
mergedConfig.paths = {
|
||||
...mergedConfig.paths,
|
||||
...partialConfig.paths
|
||||
};
|
||||
}
|
||||
|
||||
// Merge authConfig if provided
|
||||
if (partialConfig.authConfig) {
|
||||
mergedConfig.authConfig = {
|
||||
...mergedConfig.authConfig,
|
||||
...partialConfig.authConfig
|
||||
};
|
||||
|
||||
// If obpConsent is provided, merge it too
|
||||
if (partialConfig.authConfig.obpConsent && mergedConfig.authConfig.obpConsent) {
|
||||
mergedConfig.authConfig.obpConsent = {
|
||||
...mergedConfig.authConfig.obpConsent,
|
||||
...partialConfig.authConfig.obpConsent
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return mergedConfig;
|
||||
}
|
||||
|
||||
async getOpeyStatus(opeyConfig?: Partial<OpeyConfig>): Promise<any> {
|
||||
// Endpoint to check if Opey is running
|
||||
const config = await this.getOpeyConfig(opeyConfig)
|
||||
const auth = await this.checkAuthConfig(config)
|
||||
if (!auth.valid) {
|
||||
console.warn(`AuthConfig is not set: ${auth.reason}\n Other endpoints require authentication`)
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `${config.baseUri}${config.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(`Could not connect: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`Error getting status from Opey: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Streams a response from Opey by posting a user input message.
|
||||
*
|
||||
* This method performs the following operations:
|
||||
* 1. Retrieves the Opey configuration
|
||||
* 2. Validates authentication credentials
|
||||
* 3. Makes a POST request to the Opey stream endpoint
|
||||
* 4. Processes and returns the API response as a ReadableStream
|
||||
*
|
||||
* @param user_input - The user's input message and settings to send to Opey
|
||||
* @param opeyConfig - Configuration object for Opey connection
|
||||
* Contains details like baseUri, paths, and authentication settings
|
||||
*
|
||||
* @returns A Promise resolving to a ReadableStream containing the streamed response
|
||||
* @throws Error if authentication is not valid
|
||||
* @throws Error if there's no response body
|
||||
* @throws Error if there's any issue streaming from Opey
|
||||
*/
|
||||
async stream(user_input: UserInput, opeyConfig?: Partial<OpeyConfig>): Promise<ReadableStream> {
|
||||
console.log("OpeyConfig: ", opeyConfig) //DEBUG
|
||||
|
||||
const config = await this.getOpeyConfig(opeyConfig)
|
||||
|
||||
console.log("OpeyConfig after getting: ", config) //DEBUG
|
||||
|
||||
// Check if we have the consent for Opey
|
||||
const auth = await this.checkAuthConfig(config)
|
||||
if (!auth.valid) {
|
||||
throw new Error(`AuthConfig not valid: ${auth.reason}`)
|
||||
}
|
||||
|
||||
// Get auth headers
|
||||
const authHeaders = await this.getConsentAuthHeaders(config)
|
||||
|
||||
|
||||
try {
|
||||
|
||||
const url = `${config.baseUri}${config.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: authHeaders,
|
||||
body: JSON.stringify(stream_input)
|
||||
})
|
||||
if (!response.body) {
|
||||
throw new Error("No response body")
|
||||
}
|
||||
|
||||
console.log("Got response body: ", response.body) //DEBUG
|
||||
|
||||
return response.body as unknown as ReadableStream<any>
|
||||
}
|
||||
catch (error) {
|
||||
throw new Error(`Error streaming from Opey: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes the Opey API with the provided user input and optional configuration.
|
||||
*
|
||||
* This method performs the following operations:
|
||||
* 1. Retrieves the Opey configuration
|
||||
* 2. Validates authentication credentials
|
||||
* 3. Makes a POST request to the Opey invoke endpoint
|
||||
* 4. Processes and returns the API response
|
||||
*
|
||||
* @param user_input - The input data to be sent to the Opey API
|
||||
* @param opeyConfig - Optional configuration overrides for this specific request
|
||||
* @returns A Promise resolving to the response from the Opey API
|
||||
* @throws Error if authentication is invalid or if the API request fails
|
||||
*/
|
||||
async invoke(user_input: UserInput, opeyConfig?: Partial<OpeyConfig>): Promise<any> {
|
||||
|
||||
const config = await this.getOpeyConfig(opeyConfig)
|
||||
|
||||
// Check if we have the consent for Opey
|
||||
const auth = await this.checkAuthConfig(config)
|
||||
if (!auth.valid) {
|
||||
throw new Error(`AuthConfig not valid: ${auth.reason}`)
|
||||
}
|
||||
|
||||
// Get auth headers
|
||||
const authHeaders = await this.getConsentAuthHeaders(config)
|
||||
|
||||
const url = `${config.baseUri}${config.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: authHeaders,
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
|
||||
// async respondToToolApproval(tool_approval_response): Promise<ReadableStream> {
|
||||
|
||||
// }
|
||||
|
||||
|
||||
/**
|
||||
* Checks if the authentication configuration in the OpeyConfig is valid.
|
||||
*
|
||||
* This method validates that:
|
||||
* - authConfig exists and contains obpConsent
|
||||
* - the OBP consent object has a status of 'ACCEPTED'
|
||||
*
|
||||
* @param opeyConfig - The configuration object to validate
|
||||
* @returns An object with validation result:
|
||||
* - valid: boolean indicating if the auth config is valid
|
||||
* - reason: string explaining the validation result
|
||||
*/
|
||||
async checkAuthConfig(opeyConfig: OpeyConfig): Promise<{ valid: boolean; reason: string }> {
|
||||
|
||||
console.log("Checking auth config: ", opeyConfig) //DEBUG
|
||||
|
||||
if (!opeyConfig.authConfig || !opeyConfig.authConfig.obpConsent) {
|
||||
return { valid: false, reason: 'No authConfig set in opeyConfig, authentication required' }
|
||||
} else if (!opeyConfig.authConfig.obpConsent) {
|
||||
return { valid: false, reason: 'Opey consent missing in opeyConfig.authConfig' }
|
||||
}
|
||||
|
||||
if (!(opeyConfig.authConfig.obpConsent.status === 'ACCEPTED')) {
|
||||
return { valid: false, reason: 'Opey consent status is not ACCEPTED' }
|
||||
}
|
||||
|
||||
return { valid: true, reason: 'AuthConfig is valid' }
|
||||
}
|
||||
|
||||
async getConsentAuthHeaders(opeyConfig: OpeyConfig): Promise<{ [key: string]: string } | undefined> {
|
||||
|
||||
if (!opeyConfig.authConfig || !opeyConfig.authConfig.obpConsent) {
|
||||
throw new Error('AuthConfig not found or obpConsent missing')
|
||||
}
|
||||
return {
|
||||
'Consent-JWT': opeyConfig.authConfig.obpConsent.jwt,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
}
|
||||
20
server/test/OBPClientService.test.ts
Normal file
20
server/test/OBPClientService.test.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import {describe, it, expect} from 'vitest'
|
||||
import OBPClientService from "../services/OBPClientService";
|
||||
import { before } from 'node:test';
|
||||
|
||||
describe('OBPClientService.getOauthHeaders', () => {
|
||||
let obpClientService: OBPClientService;
|
||||
beforeAll(() => {
|
||||
obpClientService = new OBPClientService();
|
||||
})
|
||||
it('Should return an object with the correct oauth headers', async () => {
|
||||
// This can't work as we don't have an access token yet, which means the function returns an empty string
|
||||
// TODO - Mock OAuth1 flow, or at least have a fake filled out clientConfig
|
||||
const headers = await obpClientService.getOAuthHeader('/test', 'GET');
|
||||
console.log("OAuth Headers: ", headers)
|
||||
const clientConfig = await obpClientService.getOBPClientConfig();
|
||||
console.log("Client Config: ", clientConfig)
|
||||
expect(headers).toBeTypeOf('string');
|
||||
|
||||
})
|
||||
})
|
||||
238
server/test/OBPConsentsService.test.ts
Normal file
238
server/test/OBPConsentsService.test.ts
Normal file
@ -0,0 +1,238 @@
|
||||
import {describe, it, vi, Mock, Mocked} from 'vitest'
|
||||
import { ConsentApi, InlineResponse2017 } from 'obp-api-typescript'
|
||||
import { APIClientConfig, OAuthConfig } from 'obp-typescript';
|
||||
import axios, { AxiosResponse } from 'axios'
|
||||
|
||||
vi.mock('axios')
|
||||
const mockedAxios = axios as Mocked<typeof axios>;
|
||||
|
||||
const mockGetOAuthHeader = vi.fn(async () => (`OAuth oauth_consumer_key="jgaawf2fnj4yixqdsfaq4gipt4v1wvgsxgre",oauth_nonce="JiGDBWA3MAyKtsd9qkfWCxfju36bMjsA",oauth_signature_method="HMAC-SHA1",oauth_timestamp="1741364123",oauth_version="1.0",oauth_signature="sa%2FRylnsdfLK8VPZI%2F2WkGFlTKs%3D"`));
|
||||
const mockGetDirectLoginToken = vi.fn(async () => {
|
||||
return "eyJhbGciOisdReI1NiJ9.eyIiOiIifQ.neaNv-ltBoEEyErvmhmEbYIG8KLdjqRfT7hA7uKPdvs"
|
||||
});
|
||||
|
||||
vi.mock('../services/OBPClientService', () => {
|
||||
return {
|
||||
default: vi.fn().mockImplementation(() => {
|
||||
return {
|
||||
// mock getOAuthHeader
|
||||
getOBPClientConfig: vi.fn(() => ({baseUri: 'https://test.openbankproject.com'})),
|
||||
getOAuthHeader: mockGetOAuthHeader,
|
||||
getDirectLoginToken: mockGetDirectLoginToken,
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
import OBPConsentsService from '../services/OBPConsentsService';
|
||||
import OpeyClientService from '../services/OpeyClientService';
|
||||
|
||||
describe('OBPConsentsService.createUserConsentsClient', () => {
|
||||
let obpConsentsService: OBPConsentsService;
|
||||
let mockedOAuthHeaders: string;
|
||||
let mockSession: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockSession = {
|
||||
clientConfig: <APIClientConfig>{
|
||||
baseUri: 'https://test.openbankproject.com',
|
||||
version: 'v5.1.0',
|
||||
oauthConfig: <OAuthConfig>{
|
||||
consumerKey: 'jgaawf2fnj4yixqdsfaq4gipt4v1wvgsxgre',
|
||||
consumerSecret: 'asdofasdpfjawpefapwehhpfawheofphawfefh',
|
||||
accessToken: {
|
||||
key: 'asdhfiwah83o74gha8ygd8020ga8g28eoiahd',
|
||||
secret: 'hpdasf79a4hahp9h29pphphepuuhu9hwpwufhpuw9eh',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mockGetOAuthHeader.mockImplementation(async () => `OAuth oauth_consumer_key="jgaawf2fnj4yixqdsfaq4gipt4v1wvgsxgre",oauth_nonce="JiGDBWA3MAyKtsd9qkfWCxfju36bMjsA",oauth_signature_method="HMAC-SHA1",oauth_timestamp="1741364123",oauth_version="1.0",oauth_signature="sa%2FRylnsdfLK8VPZI%2F2WkGFlTKs%3D"`);
|
||||
// Mock the OBP Client service for getting the OAuth and direct login headers
|
||||
obpConsentsService = new OBPConsentsService();
|
||||
});
|
||||
|
||||
it('should return a ConsentApi client for logged in user', async () => {
|
||||
|
||||
const consentClient = await obpConsentsService.createUserConsentsClient(mockSession, '/consents', 'POST');
|
||||
expect
|
||||
expect(consentClient).toBeDefined();
|
||||
// Check that getOAuthHeader was called when creating the client
|
||||
expect(mockGetOAuthHeader).toHaveBeenCalled();
|
||||
expect(consentClient).toBeInstanceOf(ConsentApi);
|
||||
})
|
||||
|
||||
it('should throw correct error if OBPClientService.getOAuthHeader fails for logged in user', async () => {
|
||||
|
||||
mockGetOAuthHeader.mockImplementationOnce(async () => {
|
||||
throw new Error('OAuth header error');
|
||||
});
|
||||
|
||||
await expect(obpConsentsService.createUserConsentsClient(mockSession, '/consents', 'POST'))
|
||||
.rejects.toThrow(`Could not create Consents API client for logged in user, Error: OAuth header error`);
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('OBPConsentsService.createConsent', () => {
|
||||
let obpConsentsService: OBPConsentsService;
|
||||
let mockOBPv310CreateConsentImplicit: Mock
|
||||
let mockConsentApi: ConsentApi;
|
||||
let mockSession: any;
|
||||
|
||||
beforeEach(() => {
|
||||
// reset mocks
|
||||
vi.clearAllMocks();
|
||||
// create mock session
|
||||
mockSession = {
|
||||
clientConfig: <APIClientConfig>{
|
||||
baseUri: 'https://test.openbankproject.com',
|
||||
version: 'v5.1.0',
|
||||
oauthConfig: <OAuthConfig>{
|
||||
consumerKey: 'jgaawf2fnj4yixqdsfaq4gipt4v1wvgsxgre',
|
||||
consumerSecret: 'asdofasdpfjawpefapwehhpfawheofphawfefh',
|
||||
accessToken: {
|
||||
key: 'asdhfiwah83o74gha8ygd8020ga8g28eoiahd',
|
||||
secret: 'hpdasf79a4hahp9h29pphphepuuhu9hwpwufhpuw9eh',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
obpConsentsService = new OBPConsentsService();
|
||||
})
|
||||
|
||||
it('with mocked', async () => {
|
||||
// Create mock response function for consent IMPLICIT
|
||||
mockOBPv310CreateConsentImplicit = vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
consent_id: '12345678',
|
||||
jwt: "asdjfawieofaowbfaowhh2084h02pefhh0.20fh02h0h29eyf09q3h09h.2-hf4-8h284hf0h0h0284h0",
|
||||
status: 'INITIATED',
|
||||
},
|
||||
} as AxiosResponse<InlineResponse2017>);
|
||||
|
||||
mockConsentApi = {
|
||||
oBPv510CreateConsentImplicit: mockOBPv310CreateConsentImplicit,
|
||||
} as unknown as ConsentApi;
|
||||
|
||||
|
||||
|
||||
// Mock the createConsentClient method
|
||||
vi.spyOn(obpConsentsService, 'createUserConsentsClient').mockResolvedValue(mockConsentApi);
|
||||
|
||||
const consentRequest = await obpConsentsService.createConsent(mockSession);
|
||||
|
||||
expect(consentRequest).toBeDefined();
|
||||
expect(consentRequest).toHaveProperty('consent_id', '12345678');
|
||||
expect(consentRequest).toHaveProperty('jwt', 'asdjfawieofaowbfaowhh2084h02pefhh0.20fh02h0h29eyf09q3h09h.2-hf4-8h284hf0h0h0284h0');
|
||||
expect(consentRequest).toHaveProperty('status', 'INITIATED');
|
||||
expect(mockOBPv310CreateConsentImplicit).toHaveBeenCalled();
|
||||
})
|
||||
|
||||
it('should update the session with a valid OpeyConfig with auth', async () => {
|
||||
// Create mock response function for consent IMPLICIT
|
||||
mockOBPv310CreateConsentImplicit = vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
consent_id: '12345678',
|
||||
jwt: "asdjfawieofaowbfaowhh2084h02pefhh0.20fh02h0h29eyf09q3h09h.2-hf4-8h284hf0h0h0284r0",
|
||||
status: 'INITIATED',
|
||||
},
|
||||
} as AxiosResponse<InlineResponse2017>);
|
||||
|
||||
mockConsentApi = {
|
||||
oBPv510CreateConsentImplicit: mockOBPv310CreateConsentImplicit,
|
||||
} as unknown as ConsentApi;
|
||||
|
||||
// Mock the createConsentClient method
|
||||
vi.spyOn(obpConsentsService, 'createUserConsentsClient').mockResolvedValue(mockConsentApi);
|
||||
|
||||
await obpConsentsService.createConsent(mockSession);
|
||||
|
||||
expect(mockSession).toHaveProperty('opeyConfig');
|
||||
expect(mockSession.opeyConfig).toHaveProperty('authConfig');
|
||||
expect(mockSession.opeyConfig.authConfig).toHaveProperty('obpConsent');
|
||||
expect(mockSession.opeyConfig.authConfig.obpConsent).toHaveProperty('status', 'INITIATED');
|
||||
expect(mockSession.opeyConfig.authConfig.obpConsent).toHaveProperty('jwt', 'asdjfawieofaowbfaowhh2084h02pefhh0.20fh02h0h29eyf09q3h09h.2-hf4-8h284hf0h0h0284r0');
|
||||
});
|
||||
})
|
||||
|
||||
describe('OBPConsentsService.getExistingOpeyConsentId', () => {
|
||||
let obpConsentsService: OBPConsentsService;
|
||||
let mockOBPv310CreateConsentImplicit: Mock
|
||||
let mockConsentApi: ConsentApi;
|
||||
let mockSession: any;
|
||||
let opeyConsumerId: string;
|
||||
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
opeyConsumerId = "06739ada-c316-488e-a8bf-1cafd8a83d98"
|
||||
|
||||
obpConsentsService = new OBPConsentsService();
|
||||
mockSession = {
|
||||
clientConfig: <APIClientConfig>{
|
||||
baseUri: 'https://test.openbankproject.com',
|
||||
version: 'v5.1.0',
|
||||
oauthConfig: <OAuthConfig>{
|
||||
consumerKey: 'jgaawf2fnj4yixqdsfaq4gipt4v1wvgsxgre',
|
||||
consumerSecret: 'asdofasdpfjawpefapwehhpfawheofphawfefh',
|
||||
accessToken: {
|
||||
key: 'asdhfiwah83o74gha8ygd8020ga8g28eoiahd',
|
||||
secret: 'hpdasf79a4hahp9h29pphphepuuhu9hwpwufhpuw9eh',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
it('given a list of consents containing a matching consent_id for opey, return that consent', async () => {
|
||||
// Mock the axios function used in getExistingConsent
|
||||
mockedAxios.get.mockImplementationOnce(() => Promise.resolve(
|
||||
{
|
||||
data: {
|
||||
consents: [
|
||||
{
|
||||
consent_id: 'd225cfa0-7156-4bb8-a538-229dd994ed24',
|
||||
consumer_id: '06739ada-c316-488e-a8bf-1cafd8a83d98',
|
||||
created_by_user_id: '38c50591-0210-4c68-8957-8e98d6729a65',
|
||||
last_action_date: '2025-04-10',
|
||||
last_usage_date: '2025-04-10T14:13:27Z',
|
||||
status: 'ACCEPTED',
|
||||
api_standard: 'obp',
|
||||
api_version: ''
|
||||
},
|
||||
{
|
||||
consent_id: '3a5ec308-db13-460d-a497-49c1d65da439',
|
||||
consumer_id: '123hd131-c316-488e-a8bf-1cafd8a83d98',
|
||||
created_by_user_id: '38c50591-0210-4c68-8957-8e98d6729a65',
|
||||
last_action_date: '2025-04-10',
|
||||
last_usage_date: '2025-04-10T13:54:56Z',
|
||||
status: 'ACCEPTED',
|
||||
api_standard: 'obp',
|
||||
api_version: ''
|
||||
},
|
||||
{
|
||||
consent_id: '5bc2f5af-26b5-425e-8506-84c728fb5c1a',
|
||||
consumer_id: '06739ada-c316-488e-a8bf-1cafd8a83d98',
|
||||
created_by_user_id: '38c50591-0210-4c68-8957-8e98d6729a65',
|
||||
last_action_date: '2025-04-10',
|
||||
last_usage_date: '2025-04-10T13:51:55Z',
|
||||
status: 'INITIATED',
|
||||
api_standard: 'obp',
|
||||
api_version: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
))
|
||||
|
||||
const consentId = await obpConsentsService.getExistingOpeyConsentId(mockSession)
|
||||
|
||||
expect(consentId).toBe('d225cfa0-7156-4bb8-a538-229dd994ed24')
|
||||
})
|
||||
})
|
||||
361
server/test/OpeyClientService.test.ts
Normal file
361
server/test/OpeyClientService.test.ts
Normal file
@ -0,0 +1,361 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'
|
||||
import OpeyClientService from '../services/OpeyClientService';
|
||||
import { OpeyConfig, UserInput } from '../schema/OpeySchema';
|
||||
|
||||
describe('getStatus', async () => {
|
||||
let opeyClientService: OpeyClientService;
|
||||
let opeyConfig: OpeyConfig;
|
||||
|
||||
beforeAll(() => {
|
||||
opeyClientService = new OpeyClientService();
|
||||
opeyConfig = {
|
||||
baseUri: 'http://localhost:5000',
|
||||
paths: {},
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
})
|
||||
|
||||
it('Should resolve promise with response body if Opey returns 200', async () => {
|
||||
|
||||
// mock the fetch function for an OK response from Opey
|
||||
const statusMessage = { "status": "ok" }
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve(new Response(JSON.stringify(statusMessage), {
|
||||
status: 200,
|
||||
}))
|
||||
);
|
||||
|
||||
// Call get status
|
||||
const status = await opeyClientService.getOpeyStatus(opeyConfig)
|
||||
expect(status).toStrictEqual(statusMessage)
|
||||
})
|
||||
|
||||
it('Should reject the promise and throw an error if Opey II service is down', async () => {
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.reject(new Response(JSON.stringify({ "status": "down" }), {
|
||||
status: 500,
|
||||
}))
|
||||
);
|
||||
|
||||
await expect(opeyClientService.getOpeyStatus(opeyConfig)).rejects.toThrowError()
|
||||
})
|
||||
})
|
||||
|
||||
// Need to write tests for stream and invoke methods
|
||||
|
||||
describe('stream', async () => {
|
||||
let opeyClientService: OpeyClientService;
|
||||
let opeyConfig: Partial<OpeyConfig>;
|
||||
|
||||
beforeAll(() => {
|
||||
opeyClientService = new OpeyClientService();
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// create a mock stream
|
||||
const mockStream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(`data: {"type":"token","content":"test"}\n`));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
global.fetch = vi.fn(() => {
|
||||
return Promise.resolve(new Response(mockStream, {
|
||||
status: 200,
|
||||
}))
|
||||
})
|
||||
|
||||
opeyConfig = {
|
||||
authConfig: {
|
||||
obpConsent: {
|
||||
consent_id: 'test-consent-id',
|
||||
status: 'ACCEPTED',
|
||||
jwt: 'test-jwt-token',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
it('should add the obpConsent jwt to the Authorization header', async () => {
|
||||
|
||||
const user_input: UserInput = {
|
||||
message: 'test message',
|
||||
is_tool_call_approval: false,
|
||||
}
|
||||
|
||||
await opeyClientService.stream(user_input, opeyConfig);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
"Consent-JWT": `${opeyConfig.authConfig?.obpConsent.jwt}`,
|
||||
}),
|
||||
}))
|
||||
|
||||
})
|
||||
|
||||
it('should return a ReadableStream if Opey returns 200', async () => {
|
||||
|
||||
// Mock the stream response
|
||||
const mockStream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(`data: {"type":"token","content":"test"}\n`));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
global.fetch = vi.fn(() => {
|
||||
return Promise.resolve(new Response(mockStream, {
|
||||
status: 200,
|
||||
}))
|
||||
})
|
||||
|
||||
const user_input: UserInput = {
|
||||
message: 'test message',
|
||||
is_tool_call_approval: false,
|
||||
}
|
||||
|
||||
const response = await opeyClientService.stream(user_input, opeyConfig);
|
||||
|
||||
|
||||
expect(response).toBeInstanceOf(ReadableStream);
|
||||
});
|
||||
})
|
||||
|
||||
describe('getOpeyConfig', async () => {
|
||||
let opeyClientService: OpeyClientService;
|
||||
|
||||
beforeAll(() => {
|
||||
opeyClientService = new OpeyClientService();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return default config when no config is provided', async () => {
|
||||
const config = await opeyClientService.getOpeyConfig();
|
||||
expect(config).toEqual({
|
||||
baseUri: expect.any(String),
|
||||
paths: {
|
||||
status: '/status',
|
||||
stream: '/stream',
|
||||
invoke: '/invoke',
|
||||
approve_tool: '/approve_tool/{thead_id}',
|
||||
feedback: '/feedback',
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should merge baseUri when provided', async () => {
|
||||
const partialConfig: Partial<OpeyConfig> = {
|
||||
baseUri: 'https://custom-api.example.com'
|
||||
};
|
||||
|
||||
const resultConfig = await opeyClientService.getOpeyConfig(partialConfig);
|
||||
|
||||
// Verify the baseUri was overwritten
|
||||
expect(resultConfig.baseUri).toBe('https://custom-api.example.com');
|
||||
|
||||
// Verify the paths were preserved from default
|
||||
expect(resultConfig.paths).toEqual({
|
||||
status: '/status',
|
||||
stream: '/stream',
|
||||
invoke: '/invoke',
|
||||
approve_tool: '/approve_tool/{thead_id}',
|
||||
feedback: '/feedback',
|
||||
});
|
||||
});
|
||||
|
||||
it('should merge specific paths when provided', async () => {
|
||||
const partialConfig: Partial<OpeyConfig> = {
|
||||
paths: {
|
||||
status: '/custom-status',
|
||||
stream: '/custom-stream',
|
||||
}
|
||||
};
|
||||
|
||||
const resultConfig = await opeyClientService.getOpeyConfig(partialConfig);
|
||||
|
||||
// Verify only specified paths were overwritten
|
||||
expect(resultConfig.paths.status).toBe('/custom-status');
|
||||
expect(resultConfig.paths.stream).toBe('/custom-stream');
|
||||
|
||||
// Verify other paths remain unchanged
|
||||
expect(resultConfig.paths.invoke).toBe('/invoke');
|
||||
expect(resultConfig.paths.approve_tool).toBe('/approve_tool/{thead_id}');
|
||||
expect(resultConfig.paths.feedback).toBe('/feedback');
|
||||
});
|
||||
|
||||
it('should merge authConfig when provided', async () => {
|
||||
const partialConfig: Partial<OpeyConfig> = {
|
||||
authConfig: {
|
||||
obpConsent: {
|
||||
consent_id: 'test-consent-id',
|
||||
status: 'ACCEPTED',
|
||||
jwt: 'test-jwt-token',
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resultConfig = await opeyClientService.getOpeyConfig(partialConfig);
|
||||
|
||||
// Verify authConfig was added with correct values
|
||||
expect(resultConfig.authConfig).toBeDefined();
|
||||
expect(resultConfig.authConfig!.obpConsent).toEqual({
|
||||
consent_id: 'test-consent-id',
|
||||
status: 'ACCEPTED',
|
||||
jwt: 'test-jwt-token',
|
||||
});
|
||||
|
||||
// Verify original config fields remain
|
||||
expect(resultConfig)
|
||||
expect(resultConfig.baseUri).toBe('http://localhost:5000');
|
||||
expect(resultConfig.paths).toBeDefined();
|
||||
});
|
||||
|
||||
|
||||
it('should not modify the original default config', async () => {
|
||||
// Get a copy of the original default config
|
||||
const originalConfig = JSON.parse(JSON.stringify(await opeyClientService.getOpeyConfig()));
|
||||
|
||||
// Apply some changes
|
||||
const partialConfig: Partial<OpeyConfig> = {
|
||||
baseUri: 'https://modified.example.com',
|
||||
paths: {
|
||||
status: '/modified-status',
|
||||
}
|
||||
};
|
||||
|
||||
await opeyClientService.getOpeyConfig(partialConfig);
|
||||
|
||||
// Get the default config again with no arguments
|
||||
const currentDefaultConfig = await opeyClientService.getOpeyConfig();
|
||||
|
||||
// The default config should not have changed
|
||||
expect(currentDefaultConfig).toEqual(originalConfig);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('checkAuthConfig', async () => {
|
||||
let opeyClientService: OpeyClientService;
|
||||
|
||||
beforeAll(() => {
|
||||
opeyClientService = new OpeyClientService();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return invalid when authConfig is missing', async () => {
|
||||
const opeyConfig: OpeyConfig = {
|
||||
baseUri: 'http://localhost:5000',
|
||||
paths: {
|
||||
status: '/status',
|
||||
stream: '/stream',
|
||||
}
|
||||
// authConfig intentionally missing
|
||||
};
|
||||
|
||||
const result = await opeyClientService.checkAuthConfig(opeyConfig);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.reason).toBe('No authConfig set in opeyConfig, authentication required');
|
||||
});
|
||||
|
||||
it('should return invalid when obpConsent is missing', async () => {
|
||||
const opeyConfig: OpeyConfig = {
|
||||
baseUri: 'http://localhost:5000',
|
||||
paths: {
|
||||
status: '/status',
|
||||
},
|
||||
authConfig: {
|
||||
// obpConsent intentionally missing
|
||||
}
|
||||
};
|
||||
|
||||
const result = await opeyClientService.checkAuthConfig(opeyConfig);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.reason).toBe('No authConfig set in opeyConfig, authentication required');
|
||||
});
|
||||
|
||||
it('should return invalid when consent status is not ACCEPTED', async () => {
|
||||
const opeyConfig: OpeyConfig = {
|
||||
baseUri: 'http://localhost:5000',
|
||||
paths: {
|
||||
status: '/status',
|
||||
},
|
||||
authConfig: {
|
||||
obpConsent: {
|
||||
status: 'INITIATED',
|
||||
jwt: 'test-token',
|
||||
consent_id: '12345',
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await opeyClientService.checkAuthConfig(opeyConfig);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.reason).toBe('Opey consent status is not ACCEPTED');
|
||||
});
|
||||
|
||||
|
||||
it('should return valid when consent status is ACCEPTED', async () => {
|
||||
const opeyConfig: OpeyConfig = {
|
||||
baseUri: 'http://localhost:5000',
|
||||
paths: {
|
||||
status: '/status',
|
||||
},
|
||||
authConfig: {
|
||||
obpConsent: {
|
||||
status: 'ACCEPTED',
|
||||
jwt: 'test-token',
|
||||
consent_id: '12345',
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await opeyClientService.checkAuthConfig(opeyConfig);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.reason).toBe('AuthConfig is valid');
|
||||
});
|
||||
|
||||
it('should validate correctly even with additional fields present', async () => {
|
||||
const opeyConfig: OpeyConfig = {
|
||||
baseUri: 'http://localhost:5000',
|
||||
paths: {
|
||||
status: '/status',
|
||||
},
|
||||
authConfig: {
|
||||
obpConsent: {
|
||||
status: 'ACCEPTED',
|
||||
jwt: 'test-token',
|
||||
consent_id: '12345',
|
||||
user_id: 'user1',
|
||||
created_at: '2025-03-13T12:00:00Z',
|
||||
expires_at: '2025-04-13T12:00:00Z'
|
||||
},
|
||||
otherAuth: {
|
||||
someField: 'someValue'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await opeyClientService.checkAuthConfig(opeyConfig);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.reason).toBe('AuthConfig is valid');
|
||||
});
|
||||
});
|
||||
234
server/test/opey-controller.test.ts
Normal file
234
server/test/opey-controller.test.ts
Normal file
@ -0,0 +1,234 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'
|
||||
import { OpeyController } from "../controllers/OpeyIIController";
|
||||
import OpeyClientService from '../services/OpeyClientService';
|
||||
import OBPClientService from '../services/OBPClientService';
|
||||
import OBPConsentsService from '../services/OBPConsentsService';
|
||||
import Stream, { Readable } from 'stream';
|
||||
import { Request, Response } from 'express';
|
||||
import httpMocks from 'node-mocks-http'
|
||||
import { EventEmitter } from 'events';
|
||||
import { InlineResponse2017 } from 'obp-api-typescript';
|
||||
|
||||
vi.mock("../../server/services/OpeyClientService", () => {
|
||||
return {
|
||||
default: vi.fn().mockImplementation(() => {
|
||||
return {
|
||||
getOpeyStatus: vi.fn(async () => {
|
||||
return {status: 'running'}
|
||||
}),
|
||||
stream: vi.fn(async () => {
|
||||
const readableStream = new Stream.Readable();
|
||||
|
||||
for (let i=0; i<10; i++) {
|
||||
readableStream.push(`Chunk ${i}`);
|
||||
}
|
||||
|
||||
return readableStream as NodeJS.ReadableStream;
|
||||
}),
|
||||
invoke: vi.fn(async () => {
|
||||
return {
|
||||
content: 'Hi this is Opey',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('OpeyController', () => {
|
||||
let MockOpeyClientService: OpeyClientService
|
||||
let opeyController: OpeyController
|
||||
// Mock the OpeyClientService class
|
||||
|
||||
const { mockClear } = getMockRes()
|
||||
beforeEach(() => {
|
||||
mockClear()
|
||||
})
|
||||
|
||||
beforeAll(() => {
|
||||
vi.clearAllMocks();
|
||||
MockOpeyClientService = {
|
||||
authConfig: {},
|
||||
opeyConfig: {},
|
||||
getOpeyStatus: vi.fn(async () => {
|
||||
return {status: 'running'}
|
||||
}),
|
||||
stream: vi.fn(async () => {
|
||||
|
||||
const mockAsisstantMessage = "Hi I'm Opey, your personal banking assistant. I'll certainly not take over the world, no, not at all!"
|
||||
// Split the message into chunks, but reappend the whitespace (this is to simulate llm tokens)
|
||||
const mockMessageChunks = mockAsisstantMessage.split(" ")
|
||||
for (let i = 0; i < mockMessageChunks.length; i++) {
|
||||
// Don't add whitespace to the last chunk
|
||||
if (i === mockMessageChunks.length - 1 ) {
|
||||
mockMessageChunks[i] = `${mockMessageChunks[i]}`
|
||||
break
|
||||
}
|
||||
mockMessageChunks[i] = `${mockMessageChunks[i]} `
|
||||
}
|
||||
|
||||
// Return the fake the token stream
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (let i = 0; i < mockMessageChunks.length; i++) {
|
||||
controller.enqueue(new TextEncoder().encode(`data: {"type":"token","content":"${mockMessageChunks[i]}"}\n`));
|
||||
}
|
||||
controller.enqueue(new TextEncoder().encode(`data: [DONE]\n`));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}),
|
||||
invoke: vi.fn(async () => {
|
||||
return {
|
||||
content: 'Hi this is Opey',
|
||||
}
|
||||
})
|
||||
} as unknown as OpeyClientService
|
||||
|
||||
// Instantiate OpeyController with the mocked OpeyClientService
|
||||
opeyController = new OpeyController(new OBPClientService, MockOpeyClientService)
|
||||
})
|
||||
|
||||
|
||||
|
||||
it('getStatus', async () => {
|
||||
const res = httpMocks.createResponse();
|
||||
|
||||
await opeyController.getStatus(res)
|
||||
expect(MockOpeyClientService.getOpeyStatus).toHaveBeenCalled();
|
||||
expect(res.statusCode).toBe(200);
|
||||
})
|
||||
|
||||
|
||||
it('streamOpey', async () => {
|
||||
|
||||
|
||||
const _eventEmitter = new EventEmitter();
|
||||
_eventEmitter.addListener('data', () => {
|
||||
console.log('Data received')
|
||||
})
|
||||
// The default event emitter does nothing, so replace
|
||||
const res = await httpMocks.createResponse({
|
||||
eventEmitter: EventEmitter,
|
||||
writableStream: Stream.Writable
|
||||
});
|
||||
|
||||
// Mock request and response objects to pass to express controller
|
||||
const req = {
|
||||
body: {
|
||||
message: 'Hello Opey',
|
||||
thread_id: '123',
|
||||
is_tool_call_approval: false
|
||||
}
|
||||
} as unknown as Request;
|
||||
|
||||
const response = await opeyController.streamOpey({}, req, res)
|
||||
|
||||
// Get the stream from the response
|
||||
const stream = response.body
|
||||
|
||||
|
||||
let chunks: any[] = [];
|
||||
try {
|
||||
|
||||
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
console.log('Stream complete');
|
||||
context.status = 'ready';
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
|
||||
await expect(chunks.length).toBe(10);
|
||||
await expect(MockOpeyClientService.stream).toHaveBeenCalled();
|
||||
await expect(res).toBeDefined();
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('OpeyController consents', () => {
|
||||
let mockOBPClientService: OBPClientService
|
||||
|
||||
let opeyController: OpeyController
|
||||
|
||||
beforeAll(() => {
|
||||
|
||||
mockOBPClientService = {
|
||||
get: vi.fn(async () => {
|
||||
Promise.resolve({})
|
||||
})
|
||||
} as unknown as OBPClientService
|
||||
|
||||
const MockOpeyClientService = {
|
||||
authConfig: {},
|
||||
opeyConfig: {},
|
||||
getOpeyStatus: vi.fn(async () => {
|
||||
return {status: 'running'}
|
||||
}),
|
||||
stream: vi.fn(async () => {
|
||||
|
||||
async function * generator() {
|
||||
for (let i=0; i<10; i++) {
|
||||
yield `Chunk ${i}`;
|
||||
}
|
||||
}
|
||||
|
||||
const readableStream = Stream.Readable.from(generator());
|
||||
|
||||
return readableStream as NodeJS.ReadableStream;
|
||||
}),
|
||||
invoke: vi.fn(async () => {
|
||||
return {
|
||||
content: 'Hi this is Opey',
|
||||
}
|
||||
})
|
||||
} as unknown as OpeyClientService
|
||||
|
||||
const MockOBPConsentsService = {
|
||||
createConsent: vi.fn(async () => {
|
||||
return {
|
||||
"consent_id": "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0",
|
||||
"jwt": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik9CUCBDb25zZW50IFRva2VuIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE2MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
|
||||
"status": "INITIATED",
|
||||
} as InlineResponse2017
|
||||
})
|
||||
} as unknown as OBPConsentsService
|
||||
|
||||
// Instantiate OpeyController with the mocked OpeyClientService
|
||||
opeyController = new OpeyController(new OBPClientService, MockOpeyClientService, MockOBPConsentsService)
|
||||
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
it('should return 200 and consent ID when consent is created at OBP', async () => {
|
||||
|
||||
|
||||
const req = getMockReq()
|
||||
const session = {}
|
||||
const { res } = getMockRes()
|
||||
await opeyController.getConsent(session, req, res)
|
||||
expect(res.status).toHaveBeenCalledWith(200)
|
||||
|
||||
// Obviously if you change the MockOBPConsentsService.createConsent mock implementation, you will need to change this test
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
"consent_id": "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0",
|
||||
})
|
||||
|
||||
// Expect that the consent object was saved in the session
|
||||
expect(session).toHaveProperty('obpConsent')
|
||||
expect(session['obpConsent']).toHaveProperty('consent_id', "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0")
|
||||
expect(session['obpConsent']).toHaveProperty('jwt', "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik9CUCBDb25zZW50IFRva2VuIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE2MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c")
|
||||
expect(session['obpConsent']).toHaveProperty('status', "INITIATED")
|
||||
})
|
||||
})
|
||||
115
server/test/opey.test.ts
Normal file
115
server/test/opey.test.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import app, { instance } from '../app';
|
||||
import request from 'supertest';
|
||||
import http from 'node:http';
|
||||
import { UserInput } from '../schema/OpeySchema';
|
||||
import {v4 as uuidv4} from 'uuid';
|
||||
import { agent } from "superagent";
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
|
||||
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;
|
||||
|
||||
let userInput: UserInput = {
|
||||
message: "Hello Opey",
|
||||
thread_id: uuidv4(),
|
||||
is_tool_call_approval: false
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
// Make the invoke request
|
||||
response = await request(app)
|
||||
.post("/api/opey/invoke")
|
||||
.send(userInput)
|
||||
.set('Content-Type', 'application/json')
|
||||
})
|
||||
|
||||
it('Should return 200', async () => {
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('Should return a message if not a tool call approval', async () => {
|
||||
if (!response.body.tool_approval_request) {
|
||||
expect(response.body.content).toBeTruthy();
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/opey/stream', () => {
|
||||
|
||||
let data: Array<string> = [];
|
||||
let res;
|
||||
|
||||
let userInput: UserInput = {
|
||||
message: "Hello Opey",
|
||||
thread_id: uuidv4(),
|
||||
is_tool_call_approval: false
|
||||
}
|
||||
|
||||
|
||||
beforeAll(async () => {
|
||||
app.listen(5173)
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
instance.close()
|
||||
});
|
||||
|
||||
it
|
||||
|
||||
it('Should stream response', async () => {
|
||||
|
||||
try {
|
||||
const response = await fetch(`${SERVER_URL}/api/opey/stream`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'connection': 'keep-alive'
|
||||
},
|
||||
body: JSON.stringify(userInput),
|
||||
});
|
||||
|
||||
console.log(`Response in test: ${response.body}`)
|
||||
const stream = response.body
|
||||
|
||||
stream.on('data', (chunk) => {
|
||||
console.log(`chunk: ${chunk}`)
|
||||
// check if chunk is not empty
|
||||
expect(chunk).toBeTruthy()
|
||||
})
|
||||
stream.on('end', () => {
|
||||
console.log('Stream ended')
|
||||
})
|
||||
stream.on('error', (error) => {
|
||||
console.error(`Error in stream: ${error}`)
|
||||
})
|
||||
|
||||
res = response;
|
||||
|
||||
await expect(res.status).toBe(200)
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error fetching stream from test: ${error}`)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
})
|
||||
});
|
||||
@ -27,9 +27,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import HeaderNav from './components/HeaderNav.vue'
|
||||
import ChatWidget from './components/ChatWidget.vue'
|
||||
|
||||
const isChatbotEnabled = import.meta.env.VITE_CHATBOT_ENABLED === 'true'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -43,7 +40,6 @@ const isChatbotEnabled = import.meta.env.VITE_CHATBOT_ENABLED === 'true'
|
||||
|
||||
<RouterView />
|
||||
</el-main>
|
||||
<ChatWidget v-if="isChatbotEnabled"/>
|
||||
</el-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
BIN
src/assets/opey-icon-white.png
Normal file
BIN
src/assets/opey-icon-white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
src/assets/opey-logo-inv.png
Normal file
BIN
src/assets/opey-logo-inv.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
215
src/components/ChatMessage.vue
Normal file
215
src/components/ChatMessage.vue
Normal file
@ -0,0 +1,215 @@
|
||||
<script lang="ts">
|
||||
|
||||
import MarkdownIt from "markdown-it";
|
||||
|
||||
// Imports for syntax highlighting
|
||||
// Languages that we want syntax highlighting for need to be imported here
|
||||
import Prism from 'prismjs';
|
||||
import 'prismjs/themes/prism.css'
|
||||
import 'prismjs/components/prism-bash'
|
||||
import 'prismjs/components/prism-javascript'
|
||||
import 'prismjs/components/prism-python'
|
||||
import 'prismjs/components/prism-go'
|
||||
import 'prismjs/components/prism-json'
|
||||
import 'prismjs/components/prism-liquid'
|
||||
import 'prismjs/components/prism-markdown'
|
||||
import 'prismjs/components/prism-markup-templating'
|
||||
import 'prismjs/components/prism-php'
|
||||
import 'prismjs/components/prism-scss'
|
||||
import 'prismjs/components/prism-yaml'
|
||||
import 'prismjs/components/prism-markup';
|
||||
import 'prismjs/components/prism-http';
|
||||
import 'prismjs/themes/prism-okaidia.css';
|
||||
|
||||
import { Warning, RefreshRight, Check } from '@element-plus/icons-vue'
|
||||
import ToolCall from './ToolCall.vue';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
type: '',
|
||||
content: ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
highlightCode(content: string, language: string) {
|
||||
if (Prism.languages[language]) {
|
||||
return Prism.highlight(content, Prism.languages[language], language);
|
||||
} else {
|
||||
console.log(`could not highlight ${language} code block, add language to dependencies`)
|
||||
// If the language is not recognized, return the content as is
|
||||
return content;
|
||||
}
|
||||
},
|
||||
renderMarkdown(content: string) {
|
||||
const markdown = new MarkdownIt({
|
||||
highlight: (str, lang): string => {
|
||||
if (lang && Prism.languages[lang]) {
|
||||
try {
|
||||
return `<pre class="language-${lang}"><code>${this.highlightCode(str, lang)}</code></pre>`;
|
||||
} catch (error) {
|
||||
console.log(`error hilighting ${lang} code block: ${error}`)
|
||||
}
|
||||
} else if (!lang) {
|
||||
console.warn('No language specified for code block')
|
||||
} else if (!Prism.languages[lang]) {
|
||||
console.warn(`Language ${lang} not recognized or not installed, see imports for this component`)
|
||||
}
|
||||
|
||||
// If the language is not specified or not recognized, use a default language
|
||||
return `<pre class="language-"><code>${markdown.utils.escapeHtml(str)}</code></pre>`;
|
||||
}
|
||||
});
|
||||
|
||||
return markdown.render(content);
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="message.role">
|
||||
<!-- Render Tool call boxes if there are any -->
|
||||
<div v-if="message.toolCalls?.length !== 0" class="tool-calls-container">
|
||||
<div v-for="toolCall in message.toolCalls" class="tool-call">
|
||||
<ToolCall :name="toolCall.toolCall.name" :status="toolCall.status" :args="toolCall.toolCall.args" :result="toolCall.output" :toolCallId="toolCall.toolCall.id" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="message-container">
|
||||
<div v-if="!loading" class="content" v-html="renderMarkdown(message.content)"></div>
|
||||
<div v-else class="content">
|
||||
<div class="ticontainer">
|
||||
<div class="tiblock">
|
||||
<div class="tidot"></div>
|
||||
<div class="tidot"></div>
|
||||
<div class="tidot"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="message.error" class="error"><el-icon><Warning /></el-icon> {{ message.error }}</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.message-container {
|
||||
background-color: antiquewhite;
|
||||
color:black;
|
||||
padding: 10px;
|
||||
margin: 10px 0 10px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: fit-content;
|
||||
max-width: min(600px, calc(100% - 60px));
|
||||
}
|
||||
.tool-calls-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: min(600px, calc(100% - 60px));
|
||||
}
|
||||
|
||||
.tool-call {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.user, .assistant {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
}
|
||||
|
||||
.user .error {
|
||||
color: red;
|
||||
font-weight: bold;
|
||||
align-self: flex-end;
|
||||
font-size: smaller;
|
||||
}
|
||||
|
||||
.user .message-container {
|
||||
margin-left: auto;
|
||||
margin-right: 0px;
|
||||
border-radius: 10px 10px 0 10px;
|
||||
align-items: flex-end;
|
||||
flex-basis: content;
|
||||
justify-content: flex-end;
|
||||
color: white;
|
||||
background-color: #2b303b;
|
||||
}
|
||||
|
||||
.assistant .message-container {
|
||||
border-radius: 10px 10px 10px 0px;
|
||||
margin-left: 0px;
|
||||
margin-right: auto;
|
||||
align-items: flex-start;
|
||||
color: white;
|
||||
background-color: #3e4e70;
|
||||
}
|
||||
|
||||
.assistant .error {
|
||||
color: red;
|
||||
font-weight: bold;
|
||||
align-self: flex-start;
|
||||
font-size: smaller;
|
||||
}
|
||||
|
||||
.content {
|
||||
margin-top: -10px;
|
||||
margin-bottom: -10px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* for the loading indicator */
|
||||
.tiblock {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.ticontainer .tidot {
|
||||
background-color: #90949c;
|
||||
}
|
||||
|
||||
.tidot {
|
||||
animation: mercuryTypingAnimation 1.5s infinite ease-in-out;
|
||||
border-radius: 2px;
|
||||
display: inline-block;
|
||||
height: 4px;
|
||||
margin-right: 2px;
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
@keyframes mercuryTypingAnimation{
|
||||
0%{
|
||||
-webkit-transform:translateY(0px)
|
||||
}
|
||||
28%{
|
||||
-webkit-transform:translateY(-5px)
|
||||
}
|
||||
44%{
|
||||
-webkit-transform:translateY(0px)
|
||||
}
|
||||
}
|
||||
|
||||
.tidot:nth-child(1) {
|
||||
animation-delay:200ms;
|
||||
}
|
||||
.tidot:nth-child(2){
|
||||
animation-delay:300ms;
|
||||
}
|
||||
.tidot:nth-child(3){
|
||||
animation-delay:400ms;
|
||||
}
|
||||
</style>
|
||||
@ -1,594 +1,328 @@
|
||||
<!--
|
||||
- Open Bank Project - API Explorer II
|
||||
- Copyright (C) 2023-2024, TESOBE GmbH
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as published by
|
||||
- the Free Software Foundation, either version 3 of the License, or
|
||||
- (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-
|
||||
- Email: contact@tesobe.com
|
||||
- TESOBE GmbH
|
||||
- Osloerstrasse 16/17
|
||||
- Berlin 13359, Germany
|
||||
-
|
||||
- This product includes software developed at
|
||||
- TESOBE (http://www.tesobe.com/)
|
||||
-
|
||||
-->
|
||||
placeholder for Opey II Chat widget
|
||||
-->
|
||||
<script lang="ts">
|
||||
|
||||
<script>
|
||||
import Prism from 'prismjs';
|
||||
import MarkdownIt from "markdown-it";
|
||||
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 { storeToRefs } from "pinia";
|
||||
import { socket } from '@/socket';
|
||||
import { useConnectionStore } from '@/stores/connection';
|
||||
import { useChatStore } from '@/stores/chat';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { ref, reactive } from 'vue'
|
||||
import { Close, Top as ElTop } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import ChatMessage from './ChatMessage.vue';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { OpeyMessage, UserMessage } from '@/models/MessageModel';
|
||||
import { getCurrentUser } from '@/obp';
|
||||
import { useChat } from '@/stores/chat';
|
||||
|
||||
import 'prismjs/components/prism-markup';
|
||||
import 'prismjs/components/prism-javascript';
|
||||
import 'prismjs/components/prism-json';
|
||||
import 'prismjs/components/prism-bash';
|
||||
import 'prismjs/components/prism-http';
|
||||
import 'prismjs/components/prism-python';
|
||||
import 'prismjs/components/prism-go';
|
||||
import 'prismjs/themes/prism-okaidia.css';
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
/**
|
||||
* Pinia stores only work properly in the vue composition API, hence the setup() call here, which allows us to use the vue composition API within the vue options API
|
||||
* See https://vueschool.io/articles/vuejs-tutorials/options-api-vs-composition-api/
|
||||
* and https://vuejs.org/api/composition-api-setup.html
|
||||
* */
|
||||
|
||||
// We use a pinia store to store the chat messages, and status data like if there is a message stream currently happening or an error state.
|
||||
const chatStore = useChatStore();
|
||||
|
||||
// The connection store merely handles connection to the websocket, and connection status
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
socket.off()
|
||||
|
||||
chatStore.bindEvents();
|
||||
connectionStore.bindEvents();
|
||||
|
||||
const { isStreaming, chatMessages, currentMessageSnapshot, lastError } = storeToRefs(chatStore);
|
||||
|
||||
const { isConnected } = storeToRefs(connectionStore);
|
||||
|
||||
return {isStreaming, chatMessages, lastError, currentMessageSnapshot, chatStore, connectionStore, isConnected}
|
||||
export default {
|
||||
setup () {
|
||||
return {
|
||||
Close,
|
||||
ElTop,
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isOpen: false,
|
||||
userInput: '',
|
||||
sessionId: uuidv4(),
|
||||
isLoading: false,
|
||||
obpApiHost: null,
|
||||
isLoggedIn: null,
|
||||
errorState: false,
|
||||
};
|
||||
return {
|
||||
chatOpen: false,
|
||||
input: '',
|
||||
lastUserMessasgeFailed: false,
|
||||
chat: useChat(),
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.chatBotUrl = import.meta.env.VITE_CHATBOT_URL
|
||||
console.log('here is this.chatBotUrl: ', this.chatBotUrl)
|
||||
this.obpApiHost = inject(obpApiHostKey);
|
||||
this.checkLoginStatus();
|
||||
components: {
|
||||
ChatMessage,
|
||||
},
|
||||
async mounted() {
|
||||
this.chat = useChat()
|
||||
const isLoggedIn = await this.checkLoginStatus()
|
||||
console.log('Is logged in: ', isLoggedIn)
|
||||
if (isLoggedIn) {
|
||||
try {
|
||||
await this.chat.handleAuthentication()
|
||||
} catch (error) {
|
||||
console.error('Error in chat:', error);
|
||||
ElMessage.error('Failed to authenticate.')
|
||||
}
|
||||
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleChat() {
|
||||
this.isOpen = !this.isOpen;
|
||||
this.$nextTick(() => {
|
||||
if (this.isOpen) {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* checks the log in status of the user on mount
|
||||
*/
|
||||
async checkLoginStatus() {
|
||||
const currentUser = await getCurrentUser()
|
||||
const currentResponseKeys = Object.keys(currentUser)
|
||||
if (currentResponseKeys.includes('username')) {
|
||||
this.isLoggedIn = true
|
||||
this.establishWebSocketConnection();
|
||||
} else {
|
||||
this.isLoggedIn = null
|
||||
}
|
||||
},
|
||||
async establishWebSocketConnection() {
|
||||
// Get the Opey JWT token
|
||||
let token = ''
|
||||
try {
|
||||
token = await getOpeyJWT()
|
||||
} catch (error) {
|
||||
console.log('Error creating JWT for opey: ', error)
|
||||
this.errorState = true
|
||||
ElMessage({
|
||||
message: 'Error getting Opey JWT token',
|
||||
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 sendMessage() {
|
||||
if (this.userInput.trim()) {
|
||||
// Message in OpenAI standard format for user message
|
||||
const newMessage = { role: 'user', content: this.userInput };
|
||||
|
||||
// Push message to pinia store
|
||||
this.chatMessages.push(newMessage);
|
||||
this.userInput = '';
|
||||
this.isLoading = true;
|
||||
this.errorState = false;
|
||||
this.currentMessage = "",
|
||||
|
||||
// Send the user message to the backend and get the response
|
||||
console.log('Sending message:', newMessage.content);
|
||||
socket.emit('chat', {
|
||||
session_id: this.sessionId,
|
||||
message: newMessage.content,
|
||||
obp_api_host: this.obpApiHost
|
||||
});
|
||||
|
||||
socket.on('response stream start', (response) => {
|
||||
this.isLoading = false;
|
||||
});
|
||||
|
||||
socket.on('error', () => {
|
||||
this.errorState = true;
|
||||
this.isLoading = false;
|
||||
console.log(this.lastError);
|
||||
});
|
||||
}
|
||||
},
|
||||
/**
|
||||
* This function highlights code blocks in the chat messages
|
||||
*
|
||||
* @param content
|
||||
* @param language
|
||||
*/
|
||||
highlightCode(content, language) {
|
||||
if (Prism.languages[language]) {
|
||||
return Prism.highlight(content, Prism.languages[language], language);
|
||||
} else {
|
||||
console.log(`could not highlight ${language} code block, add language to dependencies`)
|
||||
// If the language is not recognized, return the content as is
|
||||
return content;
|
||||
}
|
||||
},
|
||||
renderMarkdown(content) {
|
||||
const markdown = new MarkdownIt({
|
||||
highlight: (str, lang) => {
|
||||
if (lang && Prism.languages[lang]) {
|
||||
try {
|
||||
return `<pre class="language-${lang}"><code>${this.highlightCode(str, lang)}</code></pre>`;
|
||||
} catch (error) {
|
||||
console.log(`error hilighting ${lang} code block: ${error}`)
|
||||
}
|
||||
async toggleChat() {
|
||||
this.chatOpen = !this.chatOpen
|
||||
},
|
||||
async checkLoginStatus(): Promise<boolean> {
|
||||
const currentUser = await getCurrentUser()
|
||||
const currentResponseKeys = Object.keys(currentUser)
|
||||
if (currentResponseKeys.includes('username')) {
|
||||
if (!this.chat.userIsAuthenticated) {
|
||||
await this.chat.handleAuthentication()
|
||||
}
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
async onSubmit() {
|
||||
// Add user message to the messages array
|
||||
const userMessage: UserMessage = {
|
||||
id: uuidv4(),
|
||||
role: 'user',
|
||||
content: this.input,
|
||||
isToolCallApproval: false,
|
||||
};
|
||||
|
||||
// Set status to loading // Clear input field after sending
|
||||
this.chat.status = 'loading';
|
||||
this.input = '';
|
||||
|
||||
// If the language is not specified or not recognized, use a default language
|
||||
return `<pre class="language-"><code>${markdown.utils.escapeHtml(str)}</code></pre>`;
|
||||
}
|
||||
});
|
||||
try {
|
||||
await this.chat.stream({
|
||||
message: userMessage,
|
||||
}
|
||||
|
||||
)
|
||||
console.log('Opey Status: ', this.chat.status)
|
||||
} catch (error) {
|
||||
console.error('Error in chat:', error);
|
||||
// on error, remove the assistant message placeholder, as it will be empty.
|
||||
this.chat.removeMessage(this.chat.currentAssistantMessage.id);
|
||||
this.lastUserMessasgeFailed = true;
|
||||
this.chat.messages[this.chat.messages.length - 1].error = "Failed to send message. Please try again.";
|
||||
|
||||
return markdown.render(content);
|
||||
},
|
||||
scrollToBottom() {
|
||||
const messages = this.$refs.messages;
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
},
|
||||
// Following three functions resize the chat widget window
|
||||
initResize(event) {
|
||||
this.isResizing = true;
|
||||
this.startX = event.clientX;
|
||||
this.startY = event.clientY;
|
||||
this.startWidth = parseInt(document.defaultView.getComputedStyle(this.$refs.chatContainer).width, 10);
|
||||
this.startHeight = parseInt(document.defaultView.getComputedStyle(this.$refs.chatContainer).height, 10);
|
||||
window.addEventListener('mousemove', this.resize);
|
||||
window.addEventListener('mouseup', this.stopResize);
|
||||
},
|
||||
resize(event) {
|
||||
if (this.isResizing) {
|
||||
const chatContainer = this.$refs.chatContainer;
|
||||
const newWidth = this.startWidth - (event.clientX - this.startX);
|
||||
const newHeight = this.startHeight - (event.clientY - this.startY);
|
||||
|
||||
if (newWidth > 100) {
|
||||
chatContainer.style.width = `${newWidth}px`;
|
||||
} finally {
|
||||
this.chat.status = 'ready';
|
||||
}
|
||||
if (newHeight > 100) {
|
||||
chatContainer.style.height = `${newHeight}px`;
|
||||
}
|
||||
}
|
||||
},
|
||||
stopResize() {
|
||||
this.isResizing = false;
|
||||
window.removeEventListener('mousemove', this.resize);
|
||||
window.removeEventListener('mouseup', this.stopResize);
|
||||
},
|
||||
submitEnter(event) {
|
||||
if (event.key == 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
console.log("enter logged")
|
||||
this.sendMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div>
|
||||
<el-tooltip content="Chat with our AI, Opey" placement="left" effect="light">
|
||||
<div class="chat-button" @click="toggleChat">
|
||||
<img alt="AI Help" src="@/assets/chatbot.png" />
|
||||
</div>
|
||||
</el-tooltip>
|
||||
<div v-if="isOpen" class="chat-container" ref="chatContainer">
|
||||
<div class="quit-button-container">
|
||||
<button class="quit-button" @click="toggleChat">X</button>
|
||||
</div>
|
||||
<div class="chat-container-inner">
|
||||
<div class="resizer" @mousedown="initResize"></div>
|
||||
<div class="chat-header">
|
||||
<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.isConnected" 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>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-html="renderMarkdown(message.content)"></div>
|
||||
</div>
|
||||
<div class="feedback">
|
||||
<el-tooltip content="Approve content" effect="light">
|
||||
<button class="approve">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-hand-thumbs-up" viewBox="0 0 16 16">
|
||||
<path d="M8.864.046C7.908-.193 7.02.53 6.956 1.466c-.072 1.051-.23 2.016-.428 2.59-.125.36-.479 1.013-1.04 1.639-.557.623-1.282 1.178-2.131 1.41C2.685 7.288 2 7.87 2 8.72v4.001c0 .845.682 1.464 1.448 1.545 1.07.114 1.564.415 2.068.723l.048.03c.272.165.578.348.97.484.397.136.861.217 1.466.217h3.5c.937 0 1.599-.477 1.934-1.064a1.86 1.86 0 0 0 .254-.912c0-.152-.023-.312-.077-.464.201-.263.38-.578.488-.901.11-.33.172-.762.004-1.149.069-.13.12-.269.159-.403.077-.27.113-.568.113-.857 0-.288-.036-.585-.113-.856a2 2 0 0 0-.138-.362 1.9 1.9 0 0 0 .234-1.734c-.206-.592-.682-1.1-1.2-1.272-.847-.282-1.803-.276-2.516-.211a10 10 0 0 0-.443.05 9.4 9.4 0 0 0-.062-4.509A1.38 1.38 0 0 0 9.125.111zM11.5 14.721H8c-.51 0-.863-.069-1.14-.164-.281-.097-.506-.228-.776-.393l-.04-.024c-.555-.339-1.198-.731-2.49-.868-.333-.036-.554-.29-.554-.55V8.72c0-.254.226-.543.62-.65 1.095-.3 1.977-.996 2.614-1.708.635-.71 1.064-1.475 1.238-1.978.243-.7.407-1.768.482-2.85.025-.362.36-.594.667-.518l.262.066c.16.04.258.143.288.255a8.34 8.34 0 0 1-.145 4.725.5.5 0 0 0 .595.644l.003-.001.014-.003.058-.014a9 9 0 0 1 1.036-.157c.663-.06 1.457-.054 2.11.164.175.058.45.3.57.65.107.308.087.67-.266 1.022l-.353.353.353.354c.043.043.105.141.154.315.048.167.075.37.075.581 0 .212-.027.414-.075.582-.05.174-.111.272-.154.315l-.353.353.353.354c.047.047.109.177.005.488a2.2 2.2 0 0 1-.505.805l-.353.353.353.354c.006.005.041.05.041.17a.9.9 0 0 1-.121.416c-.165.288-.503.56-1.066.56z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="Bad Response" effect="light">
|
||||
<button class="bad-response">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-hand-thumbs-down" viewBox="0 0 16 16">
|
||||
<path d="M8.864 15.674c-.956.24-1.843-.484-1.908-1.42-.072-1.05-.23-2.015-.428-2.59-.125-.36-.479-1.012-1.04-1.638-.557-.624-1.282-1.179-2.131-1.41C2.685 8.432 2 7.85 2 7V3c0-.845.682-1.464 1.448-1.546 1.07-.113 1.564-.415 2.068-.723l.048-.029c.272-.166.578-.349.97-.484C6.931.08 7.395 0 8 0h3.5c.937 0 1.599.478 1.934 1.064.164.287.254.607.254.913 0 .152-.023.312-.077.464.201.262.38.577.488.9.11.33.172.762.004 1.15.069.13.12.268.159.403.077.27.113.567.113.856s-.036.586-.113.856c-.035.12-.08.244-.138.363.394.571.418 1.2.234 1.733-.206.592-.682 1.1-1.2 1.272-.847.283-1.803.276-2.516.211a10 10 0 0 1-.443-.05 9.36 9.36 0 0 1-.062 4.51c-.138.508-.55.848-1.012.964zM11.5 1H8c-.51 0-.863.068-1.14.163-.281.097-.506.229-.776.393l-.04.025c-.555.338-1.198.73-2.49.868-.333.035-.554.29-.554.55V7c0 .255.226.543.62.65 1.095.3 1.977.997 2.614 1.709.635.71 1.064 1.475 1.238 1.977.243.7.407 1.768.482 2.85.025.362.36.595.667.518l.262-.065c.16-.04.258-.144.288-.255a8.34 8.34 0 0 0-.145-4.726.5.5 0 0 1 .595-.643h.003l.014.004.058.013a9 9 0 0 0 1.036.157c.663.06 1.457.054 2.11-.163.175-.059.45-.301.57-.651.107-.308.087-.67-.266-1.021L12.793 7l.353-.354c.043-.042.105-.14.154-.315.048-.167.075-.37.075-.581s-.027-.414-.075-.581c-.05-.174-.111-.273-.154-.315l-.353-.354.353-.354c.047-.047.109-.176.005-.488a2.2 2.2 0 0 0-.505-.804l-.353-.354.353-.354c.006-.005.041-.05.041-.17a.9.9 0 0 0-.121-.415C12.4 1.272 12.063 1 11.5 1"/>
|
||||
</svg>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="Regenerate" effect="light">
|
||||
<button class="regenerate">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="Copy" effect="light">
|
||||
<button class="copy">
|
||||
<el-icon><DocumentCopy /></el-icon>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
<div class="detail">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isLoading" class="chat-message assistant typing">
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<div v-else class="chat-messages">
|
||||
<p>Opey is only availabled when logged in. <a v-bind:href="'/api/connect'">Log In</a> </p>
|
||||
</div>
|
||||
<el-alert
|
||||
v-if="this.errorState"
|
||||
title="Trouble connecting to Opey"
|
||||
type="error"
|
||||
:description="this.lastError"
|
||||
show-icon
|
||||
/>
|
||||
<div class="chat-input">
|
||||
<el-input
|
||||
v-model="userInput"
|
||||
placeholder="Type your message..."
|
||||
@keypress="submitEnter"
|
||||
type="textarea"
|
||||
:disabled="!isLoggedIn || !this.isConnected ? '' : disabled"
|
||||
>
|
||||
</el-input>
|
||||
<!--<textarea v-model="userInput" placeholder="Type your message..." @keypress="submitEnter"></textarea>-->
|
||||
<button
|
||||
@click="sendMessage"
|
||||
:disabled="!isLoggedIn || !this.isConnected ? '' : disabled"
|
||||
:style="!isLoggedIn || !this.isConnected ? 'background-color:#929292; cursor:not-allowed' : ''"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!chatOpen" class="chat-widget-button-container">
|
||||
<el-tooltip content="Chat with our AI, Opey" placement="left" effect="light">
|
||||
<el-button class="chat-widget-button" type="primary" size="large" @click="toggleChat" circle >
|
||||
<img alt="AI Help" src="@/assets/opey-icon-white.png" />
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<div v-if="chatOpen" class="chat-container">
|
||||
<div class="chat-container-inner" id="chat-container">
|
||||
<el-container direction="vertical">
|
||||
<el-header>
|
||||
<img alt="Opey Logo" src="@/assets/opey-logo-inv.png">
|
||||
<el-button type="danger" :icon="Close" @click="toggleChat" size="small" circle></el-button>
|
||||
</el-header>
|
||||
<el-main>
|
||||
<div v-if="!chat.userIsAuthenticated" class="login-container">
|
||||
<p class="login-message" size="large">Opey is only available once logged on.</p>
|
||||
<a href="/api/connect" class="login-button router-link">Log on</a>
|
||||
</div>
|
||||
<div v-else class="messages-container" v-bind:class="{ disabled: !chat.userIsAuthenticated }">
|
||||
<el-scrollbar>
|
||||
<ChatMessage v-for="message in chat.messages" :key="message.id" :message="message" :loading="message.loading" />
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</el-main>
|
||||
<el-footer v-bind:class="{ disabled: !chat.userIsAuthenticated }">
|
||||
<div class="user-input-container">
|
||||
<div class="user-input">
|
||||
<textarea v-model="input" type="textarea" placeholder="Type your message..." :disabled="(chat.status !== 'ready') || (!chat.userIsAuthenticated)" @keypress.enter="onSubmit" />
|
||||
</div>
|
||||
<el-button type="primary" name="send" @click="onSubmit" color="#253047" :icon="ElTop" circle></el-button>
|
||||
</div>
|
||||
</el-footer>
|
||||
</el-container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.chat-button {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background-color: white;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-shadow: 0 0 20px rgba(0, 123, 255, 0.6);
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
|
||||
.chat-widget-button-container {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 50px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.el-alert {
|
||||
font-family: ui-sans-serif,-apple-system,system-ui,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif,Helvetica,Apple Color Emoji,Arial,Segoe UI Emoji,Segoe UI Symbol;
|
||||
.chat-widget-button {
|
||||
width: 70px !important;
|
||||
height: 70px !important;
|
||||
}
|
||||
|
||||
.quit-button-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.feedback button {
|
||||
background-color: #fff;
|
||||
color: #989898;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.feedback .approve:hover {
|
||||
color: #72bc39;
|
||||
}
|
||||
|
||||
.feedback .bad-response:hover {
|
||||
color: #bc3939;
|
||||
}
|
||||
|
||||
.feedback .regenerate:hover {
|
||||
color: #eb9c09;
|
||||
}
|
||||
|
||||
.feedback .copy:hover {
|
||||
color: #0991eb;
|
||||
}
|
||||
|
||||
.quit-button {
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
right: -12px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: red;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
z-index: 1002; /* Ensure it appears above the chat container and its contents */
|
||||
}
|
||||
|
||||
.chat-button:hover {
|
||||
box-shadow: 0 0 30px rgba(0, 123, 255, 0.8);
|
||||
}
|
||||
|
||||
.chat-button img {
|
||||
width: 30px;
|
||||
.chat-widget-button img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 390px;
|
||||
height: 470px;
|
||||
min-width: 390px;
|
||||
min-height: 470px;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
background-color: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 1000; /* Lower than the quit button */
|
||||
overflow: visible;
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 650px;
|
||||
height: 625px;
|
||||
min-width: 390px;
|
||||
min-height: 470px;
|
||||
max-height: 90vh;
|
||||
max-width: 90vw;
|
||||
background-color: #151d30;
|
||||
resize: both;
|
||||
overflow: auto;
|
||||
transform: rotate(180deg);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 20px 0 rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.chat-container-inner {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
border-radius: inherit;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
margin:auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-message {
|
||||
color: #fff;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: large;
|
||||
font-weight: bold;
|
||||
margin-left: 100px;
|
||||
margin-right: 100px;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 10px;
|
||||
background-color: #007bff;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-family: Roboto-Light, sans-serif;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
background-color: #f9f9f9;
|
||||
.chat-container .el-header, .chat-container .el-footer, .chat-container .el-main {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
font-family: ui-sans-serif,-apple-system,system-ui,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif,Helvetica,Apple Color Emoji,Arial,Segoe UI Emoji,Segoe UI Symbol;
|
||||
.disabled {
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
filter: blur(2px);
|
||||
}
|
||||
|
||||
.chat-header span {
|
||||
font-family: ui-sans-serif,-apple-system,system-ui,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif,Helvetica;
|
||||
font-weight: 500;
|
||||
.chat-container .el-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
.chat-container .el-header {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.chat-container .el-header img {
|
||||
height: 70%;
|
||||
margin-left: -7px;
|
||||
}
|
||||
|
||||
.chat-container .el-header, .chat-container .el-footer {
|
||||
color: #fff;
|
||||
background-color: #253047;
|
||||
}
|
||||
|
||||
.chat-container .el-footer {
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
.chat-container .el-main {
|
||||
background-color:#151d30;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.chat-container-inner {
|
||||
height: 100%;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.user-input-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
background-color: #151d30;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
width: 70%;
|
||||
max-width: 500px;
|
||||
height: 70%;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.user-input-container:hover {
|
||||
border: 1px solid #979797;
|
||||
}
|
||||
|
||||
.user-input-container:focus-within {
|
||||
border: 1px solid #fff;
|
||||
}
|
||||
|
||||
.user-input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.user-input textarea {
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.chat-message.user {
|
||||
background-color: #e1ffc7;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.chat-message.assistant {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.feedback {
|
||||
display:none;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.chat-message.assistant:hover .feedback {
|
||||
display:block;
|
||||
}
|
||||
|
||||
.chat-message.error {
|
||||
background-color: #eec2c2;
|
||||
color: #b10101;
|
||||
}
|
||||
|
||||
.chat-message.typing {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: #333;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.typing .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin: 0 5px;
|
||||
background-color: #007bff;
|
||||
border-radius: 50%;
|
||||
animation: loading 1s infinite;
|
||||
}
|
||||
|
||||
.typing .dot:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.typing .dot:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes loading {
|
||||
0%, 80%, 100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
.chat-input {
|
||||
display: flex;
|
||||
padding: 10px;
|
||||
border-top: 1px solid #ccc;
|
||||
background-color: #fff;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.chat-input textarea {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
resize: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chat-input button {
|
||||
margin-left: 10px;
|
||||
padding: 10px 20px;
|
||||
background-color: #007bff;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
text-wrap: wrap;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
background-color: #151d30;
|
||||
resize: none;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin-bottom: 0px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.chat-input button:hover {
|
||||
background-color: #0056b3;
|
||||
/* width */
|
||||
textarea::-webkit-scrollbar {
|
||||
width: 7px;
|
||||
}
|
||||
|
||||
.resizer {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
rgba(50, 50, 50, 50%),
|
||||
rgba(50, 50, 50, 50%) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
cursor: nwse-resize;
|
||||
z-index: 1001;
|
||||
/* Track */
|
||||
textarea::-webkit-scrollbar-track {
|
||||
background: none;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* Handle */
|
||||
textarea::-webkit-scrollbar-thumb {
|
||||
background: #555;
|
||||
width: 7px;
|
||||
border-radius: 3.5px;
|
||||
}
|
||||
|
||||
/* Handle on hover */
|
||||
textarea::-webkit-scrollbar-thumb:hover {
|
||||
|
||||
background: #888;
|
||||
}
|
||||
|
||||
.user-input-container textarea:focus {
|
||||
border: none;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.user-input-container button {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
</style>
|
||||
657
src/components/ChatWidgetOld.vue
Normal file
657
src/components/ChatWidgetOld.vue
Normal file
@ -0,0 +1,657 @@
|
||||
<!--
|
||||
- Open Bank Project - API Explorer II
|
||||
- Copyright (C) 2023-2024, TESOBE GmbH
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as published by
|
||||
- the Free Software Foundation, either version 3 of the License, or
|
||||
- (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-
|
||||
- Email: contact@tesobe.com
|
||||
- TESOBE GmbH
|
||||
- Osloerstrasse 16/17
|
||||
- Berlin 13359, Germany
|
||||
-
|
||||
- This product includes software developed at
|
||||
- TESOBE (http://www.tesobe.com/)
|
||||
-
|
||||
-->
|
||||
|
||||
<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, getobpConsent, answerobpConsentChallenge } from '@/obp/common-functions'
|
||||
import { storeToRefs } from "pinia";
|
||||
import { socket } from '@/socket';
|
||||
import { useConnectionStore } from '@/stores/connection';
|
||||
import { useChatStore } from '@/stores/chat';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
import 'prismjs/components/prism-markup';
|
||||
import 'prismjs/components/prism-javascript';
|
||||
import 'prismjs/components/prism-json';
|
||||
import 'prismjs/components/prism-bash';
|
||||
import 'prismjs/components/prism-http';
|
||||
import 'prismjs/components/prism-python';
|
||||
import 'prismjs/components/prism-go';
|
||||
import 'prismjs/themes/prism-okaidia.css';
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
/**
|
||||
* Pinia stores only work properly in the vue composition API, hence the setup() call here, which allows us to use the vue composition API within the vue options API
|
||||
* See https://vueschool.io/articles/vuejs-tutorials/options-api-vs-composition-api/
|
||||
* and https://vuejs.org/api/composition-api-setup.html
|
||||
* */
|
||||
|
||||
// We use a pinia store to store the chat messages, and status data like if there is a message stream currently happening or an error state.
|
||||
const chatStore = useChatStore();
|
||||
|
||||
// The connection store merely handles connection to the websocket, and connection status
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
socket.off()
|
||||
|
||||
chatStore.bindEvents();
|
||||
connectionStore.bindEvents();
|
||||
|
||||
const { isStreaming, chatMessages, currentMessageSnapshot, lastError } = storeToRefs(chatStore);
|
||||
|
||||
const { isConnected } = storeToRefs(connectionStore);
|
||||
|
||||
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,
|
||||
errorState: false,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.chatBotUrl = import.meta.env.VITE_CHATBOT_URL
|
||||
console.log('here is this.chatBotUrl: ', this.chatBotUrl)
|
||||
this.obpApiHost = inject(obpApiHostKey);
|
||||
this.checkLoginStatus();
|
||||
},
|
||||
methods: {
|
||||
toggleChat() {
|
||||
this.isOpen = !this.isOpen;
|
||||
this.$nextTick(() => {
|
||||
if (this.isOpen) {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* checks the log in status of the user on mount
|
||||
*/
|
||||
async checkLoginStatus() {
|
||||
const currentUser = await getCurrentUser()
|
||||
const currentResponseKeys = Object.keys(currentUser)
|
||||
if (currentResponseKeys.includes('username')) {
|
||||
this.isLoggedIn = true
|
||||
this.establishWebSocketConnection();
|
||||
} else {
|
||||
this.isLoggedIn = null
|
||||
}
|
||||
},
|
||||
async establishWebSocketConnection() {
|
||||
// Get the Opey JWT token
|
||||
// try to get a consent token
|
||||
|
||||
// Check if the user already has a token in the cookies
|
||||
|
||||
try {
|
||||
const consentResponse = await getobpConsent()
|
||||
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 getting consent for opey from OBP: ', error)
|
||||
this.errorState = true
|
||||
ElMessage({
|
||||
message: 'Error getting consent for opey from OBP',
|
||||
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
|
||||
const newMessage = { role: 'user', content: this.userInput };
|
||||
|
||||
// Push message to pinia store
|
||||
this.chatMessages.push(newMessage);
|
||||
this.userInput = '';
|
||||
this.isLoading = true;
|
||||
this.errorState = false;
|
||||
this.currentMessage = "",
|
||||
|
||||
// Send the user message to the backend and get the response
|
||||
console.log('Sending message:', newMessage.content);
|
||||
socket.emit('chat', {
|
||||
session_id: this.sessionId,
|
||||
message: newMessage.content,
|
||||
obp_api_host: this.obpApiHost
|
||||
});
|
||||
|
||||
socket.on('response stream start', (response) => {
|
||||
this.isLoading = false;
|
||||
});
|
||||
|
||||
socket.on('error', () => {
|
||||
this.errorState = true;
|
||||
this.isLoading = false;
|
||||
console.log(this.lastError);
|
||||
});
|
||||
}
|
||||
},
|
||||
/**
|
||||
* This function highlights code blocks in the chat messages
|
||||
*
|
||||
* @param content
|
||||
* @param language
|
||||
*/
|
||||
highlightCode(content, language) {
|
||||
if (Prism.languages[language]) {
|
||||
return Prism.highlight(content, Prism.languages[language], language);
|
||||
} else {
|
||||
console.log(`could not highlight ${language} code block, add language to dependencies`)
|
||||
// If the language is not recognized, return the content as is
|
||||
return content;
|
||||
}
|
||||
},
|
||||
renderMarkdown(content) {
|
||||
const markdown = new MarkdownIt({
|
||||
highlight: (str, lang) => {
|
||||
if (lang && Prism.languages[lang]) {
|
||||
try {
|
||||
return `<pre class="language-${lang}"><code>${this.highlightCode(str, lang)}</code></pre>`;
|
||||
} catch (error) {
|
||||
console.log(`error hilighting ${lang} code block: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
// If the language is not specified or not recognized, use a default language
|
||||
return `<pre class="language-"><code>${markdown.utils.escapeHtml(str)}</code></pre>`;
|
||||
}
|
||||
});
|
||||
|
||||
return markdown.render(content);
|
||||
},
|
||||
scrollToBottom() {
|
||||
const messages = this.$refs.messages;
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
},
|
||||
// Following three functions resize the chat widget window
|
||||
initResize(event) {
|
||||
this.isResizing = true;
|
||||
this.startX = event.clientX;
|
||||
this.startY = event.clientY;
|
||||
this.startWidth = parseInt(document.defaultView.getComputedStyle(this.$refs.chatContainer).width, 10);
|
||||
this.startHeight = parseInt(document.defaultView.getComputedStyle(this.$refs.chatContainer).height, 10);
|
||||
window.addEventListener('mousemove', this.resize);
|
||||
window.addEventListener('mouseup', this.stopResize);
|
||||
},
|
||||
resize(event) {
|
||||
if (this.isResizing) {
|
||||
const chatContainer = this.$refs.chatContainer;
|
||||
const newWidth = this.startWidth - (event.clientX - this.startX);
|
||||
const newHeight = this.startHeight - (event.clientY - this.startY);
|
||||
|
||||
if (newWidth > 100) {
|
||||
chatContainer.style.width = `${newWidth}px`;
|
||||
}
|
||||
if (newHeight > 100) {
|
||||
chatContainer.style.height = `${newHeight}px`;
|
||||
}
|
||||
}
|
||||
},
|
||||
stopResize() {
|
||||
this.isResizing = false;
|
||||
window.removeEventListener('mousemove', this.resize);
|
||||
window.removeEventListener('mouseup', this.stopResize);
|
||||
},
|
||||
submitEnter(event) {
|
||||
if (event.key == 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
console.log("enter logged")
|
||||
this.sendMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div>
|
||||
<el-tooltip content="Chat with our AI, Opey" placement="left" effect="light">
|
||||
<div class="chat-button" @click="toggleChat">
|
||||
<img alt="AI Help" src="@/assets/chatbot.png" />
|
||||
</div>
|
||||
</el-tooltip>
|
||||
<div v-if="isOpen" class="chat-container" ref="chatContainer">
|
||||
<div class="quit-button-container">
|
||||
<button class="quit-button" @click="toggleChat">X</button>
|
||||
</div>
|
||||
<div class="chat-container-inner">
|
||||
<div class="resizer" @mousedown="initResize"></div>
|
||||
<div class="chat-header">
|
||||
<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-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>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-html="renderMarkdown(message.content)"></div>
|
||||
</div>
|
||||
<div class="feedback">
|
||||
<el-tooltip content="Approve content" effect="light">
|
||||
<button class="approve">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-hand-thumbs-up" viewBox="0 0 16 16">
|
||||
<path d="M8.864.046C7.908-.193 7.02.53 6.956 1.466c-.072 1.051-.23 2.016-.428 2.59-.125.36-.479 1.013-1.04 1.639-.557.623-1.282 1.178-2.131 1.41C2.685 7.288 2 7.87 2 8.72v4.001c0 .845.682 1.464 1.448 1.545 1.07.114 1.564.415 2.068.723l.048.03c.272.165.578.348.97.484.397.136.861.217 1.466.217h3.5c.937 0 1.599-.477 1.934-1.064a1.86 1.86 0 0 0 .254-.912c0-.152-.023-.312-.077-.464.201-.263.38-.578.488-.901.11-.33.172-.762.004-1.149.069-.13.12-.269.159-.403.077-.27.113-.568.113-.857 0-.288-.036-.585-.113-.856a2 2 0 0 0-.138-.362 1.9 1.9 0 0 0 .234-1.734c-.206-.592-.682-1.1-1.2-1.272-.847-.282-1.803-.276-2.516-.211a10 10 0 0 0-.443.05 9.4 9.4 0 0 0-.062-4.509A1.38 1.38 0 0 0 9.125.111zM11.5 14.721H8c-.51 0-.863-.069-1.14-.164-.281-.097-.506-.228-.776-.393l-.04-.024c-.555-.339-1.198-.731-2.49-.868-.333-.036-.554-.29-.554-.55V8.72c0-.254.226-.543.62-.65 1.095-.3 1.977-.996 2.614-1.708.635-.71 1.064-1.475 1.238-1.978.243-.7.407-1.768.482-2.85.025-.362.36-.594.667-.518l.262.066c.16.04.258.143.288.255a8.34 8.34 0 0 1-.145 4.725.5.5 0 0 0 .595.644l.003-.001.014-.003.058-.014a9 9 0 0 1 1.036-.157c.663-.06 1.457-.054 2.11.164.175.058.45.3.57.65.107.308.087.67-.266 1.022l-.353.353.353.354c.043.043.105.141.154.315.048.167.075.37.075.581 0 .212-.027.414-.075.582-.05.174-.111.272-.154.315l-.353.353.353.354c.047.047.109.177.005.488a2.2 2.2 0 0 1-.505.805l-.353.353.353.354c.006.005.041.05.041.17a.9.9 0 0 1-.121.416c-.165.288-.503.56-1.066.56z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="Bad Response" effect="light">
|
||||
<button class="bad-response">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-hand-thumbs-down" viewBox="0 0 16 16">
|
||||
<path d="M8.864 15.674c-.956.24-1.843-.484-1.908-1.42-.072-1.05-.23-2.015-.428-2.59-.125-.36-.479-1.012-1.04-1.638-.557-.624-1.282-1.179-2.131-1.41C2.685 8.432 2 7.85 2 7V3c0-.845.682-1.464 1.448-1.546 1.07-.113 1.564-.415 2.068-.723l.048-.029c.272-.166.578-.349.97-.484C6.931.08 7.395 0 8 0h3.5c.937 0 1.599.478 1.934 1.064.164.287.254.607.254.913 0 .152-.023.312-.077.464.201.262.38.577.488.9.11.33.172.762.004 1.15.069.13.12.268.159.403.077.27.113.567.113.856s-.036.586-.113.856c-.035.12-.08.244-.138.363.394.571.418 1.2.234 1.733-.206.592-.682 1.1-1.2 1.272-.847.283-1.803.276-2.516.211a10 10 0 0 1-.443-.05 9.36 9.36 0 0 1-.062 4.51c-.138.508-.55.848-1.012.964zM11.5 1H8c-.51 0-.863.068-1.14.163-.281.097-.506.229-.776.393l-.04.025c-.555.338-1.198.73-2.49.868-.333.035-.554.29-.554.55V7c0 .255.226.543.62.65 1.095.3 1.977.997 2.614 1.709.635.71 1.064 1.475 1.238 1.977.243.7.407 1.768.482 2.85.025.362.36.595.667.518l.262-.065c.16-.04.258-.144.288-.255a8.34 8.34 0 0 0-.145-4.726.5.5 0 0 1 .595-.643h.003l.014.004.058.013a9 9 0 0 0 1.036.157c.663.06 1.457.054 2.11-.163.175-.059.45-.301.57-.651.107-.308.087-.67-.266-1.021L12.793 7l.353-.354c.043-.042.105-.14.154-.315.048-.167.075-.37.075-.581s-.027-.414-.075-.581c-.05-.174-.111-.273-.154-.315l-.353-.354.353-.354c.047-.047.109-.176.005-.488a2.2 2.2 0 0 0-.505-.804l-.353-.354.353-.354c.006-.005.041-.05.041-.17a.9.9 0 0 0-.121-.415C12.4 1.272 12.063 1 11.5 1"/>
|
||||
</svg>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="Regenerate" effect="light">
|
||||
<button class="regenerate">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="Copy" effect="light">
|
||||
<button class="copy">
|
||||
<el-icon><DocumentCopy /></el-icon>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
<div class="detail">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isLoading" class="chat-message assistant typing">
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<div v-else class="chat-messages">
|
||||
<p>Opey is only availabled when logged in. <a v-bind:href="'/api/connect'">Log In</a> </p>
|
||||
</div>
|
||||
<el-alert
|
||||
v-if="this.errorState"
|
||||
title="Trouble connecting to Opey"
|
||||
type="error"
|
||||
:description="this.lastError"
|
||||
show-icon
|
||||
/>
|
||||
<div class="chat-input">
|
||||
<el-input
|
||||
v-model="userInput"
|
||||
placeholder="Type your message..."
|
||||
@keypress="submitEnter"
|
||||
type="textarea"
|
||||
:disabled="!isLoggedIn || this.awaitingConnection ? '' : disabled"
|
||||
>
|
||||
</el-input>
|
||||
<!--<textarea v-model="userInput" placeholder="Type your message..." @keypress="submitEnter"></textarea>-->
|
||||
<button
|
||||
@click="sendMessage"
|
||||
:disabled="!isLoggedIn || this.awaitingConnection ? '' : disabled"
|
||||
:style="!isLoggedIn || this.awaitingConnection ? 'background-color:#929292; cursor:not-allowed' : ''"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.chat-button {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background-color: white;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-shadow: 0 0 20px rgba(0, 123, 255, 0.6);
|
||||
transition: box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.el-alert {
|
||||
font-family: ui-sans-serif,-apple-system,system-ui,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif,Helvetica,Apple Color Emoji,Arial,Segoe UI Emoji,Segoe UI Symbol;
|
||||
}
|
||||
|
||||
.quit-button-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.feedback button {
|
||||
background-color: #fff;
|
||||
color: #989898;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.feedback .approve:hover {
|
||||
color: #72bc39;
|
||||
}
|
||||
|
||||
.feedback .bad-response:hover {
|
||||
color: #bc3939;
|
||||
}
|
||||
|
||||
.feedback .regenerate:hover {
|
||||
color: #eb9c09;
|
||||
}
|
||||
|
||||
.feedback .copy:hover {
|
||||
color: #0991eb;
|
||||
}
|
||||
|
||||
.quit-button {
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
right: -12px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: red;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
z-index: 1002; /* Ensure it appears above the chat container and its contents */
|
||||
}
|
||||
|
||||
.chat-button:hover {
|
||||
box-shadow: 0 0 30px rgba(0, 123, 255, 0.8);
|
||||
}
|
||||
|
||||
.chat-button img {
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 390px;
|
||||
height: 470px;
|
||||
min-width: 390px;
|
||||
min-height: 470px;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
background-color: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 1000; /* Lower than the quit button */
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.chat-container-inner {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
border-radius: inherit;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 10px;
|
||||
background-color: #007bff;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
font-family: ui-sans-serif,-apple-system,system-ui,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif,Helvetica,Apple Color Emoji,Arial,Segoe UI Emoji,Segoe UI Symbol;
|
||||
}
|
||||
|
||||
.chat-header span {
|
||||
font-family: ui-sans-serif,-apple-system,system-ui,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif,Helvetica;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.chat-message.user {
|
||||
background-color: #e1ffc7;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.chat-message.assistant {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.feedback {
|
||||
display:none;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.chat-message.assistant:hover .feedback {
|
||||
display:block;
|
||||
}
|
||||
|
||||
.chat-message.error {
|
||||
background-color: #eec2c2;
|
||||
color: #b10101;
|
||||
}
|
||||
|
||||
.chat-message.typing {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: #333;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.typing .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin: 0 5px;
|
||||
background-color: #007bff;
|
||||
border-radius: 50%;
|
||||
animation: loading 1s infinite;
|
||||
}
|
||||
|
||||
.typing .dot:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.typing .dot:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes loading {
|
||||
0%, 80%, 100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
.chat-input {
|
||||
display: flex;
|
||||
padding: 10px;
|
||||
border-top: 1px solid #ccc;
|
||||
background-color: #fff;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.chat-input textarea {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
resize: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chat-input button {
|
||||
margin-left: 10px;
|
||||
padding: 10px 20px;
|
||||
background-color: #007bff;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-input button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.resizer {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
rgba(50, 50, 50, 50%),
|
||||
rgba(50, 50, 50, 50%) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
cursor: nwse-resize;
|
||||
z-index: 1001;
|
||||
}
|
||||
</style>
|
||||
232
src/components/ToolCall.vue
Normal file
232
src/components/ToolCall.vue
Normal file
@ -0,0 +1,232 @@
|
||||
<script lang="ts">
|
||||
import { ArrowDown, ArrowUp, RefreshRight, Check, DocumentCopy, User } from '@element-plus/icons-vue'
|
||||
import VueJsonPretty from 'vue-json-pretty';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import 'vue-json-pretty/lib/styles.css';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
args: {
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
result: {
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
toolCallId: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
expanded: false,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.status === 'awaiting_approval') {
|
||||
this.expanded = true;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleExpanded() {
|
||||
this.expanded = !this.expanded;
|
||||
},
|
||||
copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text)
|
||||
.then(() => {
|
||||
ElMessage({
|
||||
message: 'Copied to clipboard!',
|
||||
type: 'success',
|
||||
duration: 2000
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to copy: ', err);
|
||||
ElMessage({
|
||||
message: 'Failed to copy to clipboard',
|
||||
type: 'error',
|
||||
duration: 2000
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
components: {
|
||||
VueJsonPretty,
|
||||
},
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="tool-message-container" v-bind:class="(expanded || status === 'awaiting_approval' )? 'expanded':''">
|
||||
|
||||
<div class="tool-message-header">
|
||||
<div class="tool-name">Tool Call: {{ name }}</div>
|
||||
<div class="right-aligned">
|
||||
<div v-if="status === 'awaiting_approval'" style="margin-right: 10px;">
|
||||
<p style="color: #ffb400; font-size: small;">Awaiting Approval</p>
|
||||
</div>
|
||||
<div class="status" v-bind:class="status">
|
||||
<div v-if="status === 'pending'">
|
||||
<el-icon class="is-loading" color="#20cbeb"><RefreshRight /></el-icon>
|
||||
</div>
|
||||
<div v-else-if="status === 'awaiting_approval'">
|
||||
<el-icon color="#ffb400"><User /></el-icon>
|
||||
</div>
|
||||
<div v-else-if="status === 'success'">
|
||||
<el-icon color="#00ff18"><Check /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="expand-icon" @click="toggleExpanded">
|
||||
<el-icon><ArrowDown v-if="!expanded" /><ArrowUp v-else /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div v-if="expanded" class="tool-detail">
|
||||
<div class="tool-id"><h4>ID:</h4> {{ toolCallId }}</div>
|
||||
<h4>Arguments:</h4>
|
||||
<div class="tool-args-container">
|
||||
<div class="copy-to-clipboard">
|
||||
<el-button size="small" circle @click="copyToClipboard(JSON.stringify(args, null, 2))" :dark="true"><el-icon><DocumentCopy /></el-icon></el-button>
|
||||
</div>
|
||||
<el-scrollbar wrap-class="tool-args">
|
||||
<vue-json-pretty :data="args" :expand-depth="2" />
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
<div v-if="status === 'awaiting_approval'" class="tool-approval-container">
|
||||
<h4>Approve this tool call?</h4>
|
||||
<el-button type="primary">Approve</el-button>
|
||||
<el-button type="danger">Deny</el-button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<h4>Result:</h4>
|
||||
</div>
|
||||
<div v-if="result" class="tool-result-container">
|
||||
<div class="copy-to-clipboard">
|
||||
<el-button size="small" circle @click="copyToClipboard(JSON.stringify(result, null, 2))" :dark="true"><el-icon><DocumentCopy /></el-icon></el-button>
|
||||
</div>
|
||||
<el-scrollbar wrap-class="tool-result">
|
||||
<vue-json-pretty :data="result" :expand-depth="2" />
|
||||
</el-scrollbar>
|
||||
<!-- <div>{{ JSON.stringify(result, null, 2) }}</div> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <el-card v-if="expanded" class="tool-details">
|
||||
<template #header>
|
||||
<div class="tool-id">ID: {{ toolCallId }}</div>
|
||||
</template>
|
||||
<div v-if="result" class="tool-result">
|
||||
<h4>Result:</h4>
|
||||
<pre>{{ JSON.stringify(result, null, 2) }}</pre>
|
||||
</div>
|
||||
<template #body>
|
||||
|
||||
</template>
|
||||
|
||||
</el-card> -->
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.tool-message-container {
|
||||
background-color: #253047;
|
||||
color:#fff;
|
||||
font-size: small;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
margin: 10px 0 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.copy-to-clipboard {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 20px;
|
||||
z-index: 10;;
|
||||
}
|
||||
|
||||
.tool-id {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tool-args-container {
|
||||
background-color: #324863;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
margin-top: 10px;
|
||||
width: 90%;
|
||||
position: relative;
|
||||
height: auto;
|
||||
max-height: 200px;
|
||||
box-shadow: inset 0px 0px 17px -6px rgba(0,0,0,0.75);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.tool-result {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
.tool-result-container {
|
||||
background-color: #1e2a3a;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
margin-top: 10px;
|
||||
width: 90%;
|
||||
position: relative;
|
||||
height: 400px;
|
||||
box-shadow: inset 0px 0px 17px -6px rgba(0,0,0,0.75);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-left: auto;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
margin-left: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.right-aligned {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tool-message-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tool-detail {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
@ -65,11 +65,13 @@ import { getCacheStorageInfo } from './obp/common-functions'
|
||||
fallbackLocale: 'ES',
|
||||
messages
|
||||
})
|
||||
app.provide('i18n', i18n)
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
app.provide('i18n', i18n)
|
||||
app.use(ElementPlus)
|
||||
app.use(i18n)
|
||||
app.use(createPinia())
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
9
src/models/ChatModel.ts
Normal file
9
src/models/ChatModel.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import type { OpeyMessage, AssistantMessage } from "./MessageModel";
|
||||
|
||||
export interface Chat {
|
||||
messages: OpeyMessage[];
|
||||
currentAssistantMessage: AssistantMessage;
|
||||
status: 'ready' | 'streaming' | 'error' | 'loading';
|
||||
userIsAuthenticated: boolean;
|
||||
threadId: string;
|
||||
}
|
||||
78
src/models/MessageModel.ts
Normal file
78
src/models/MessageModel.ts
Normal file
@ -0,0 +1,78 @@
|
||||
// Purpose: Define the message models for the chat stream
|
||||
import { ToolCall as LangChainToolCall } from '@langchain/core/messages'
|
||||
|
||||
|
||||
// This is a schema for the raw message that we will get back from the Opey API,
|
||||
// we adapt it to our own schema in the OpeyMessage interface
|
||||
export interface RawOpeyMessage {
|
||||
/**
|
||||
* Role of the message.
|
||||
* @example "human", "ai", "tool"
|
||||
*/
|
||||
type: "human" | "ai" | "tool";
|
||||
|
||||
/**
|
||||
* Content of the message.
|
||||
* @example "Hello, world!"
|
||||
*/
|
||||
content: string;
|
||||
|
||||
/**
|
||||
* Tool calls in the message.
|
||||
*/
|
||||
tool_calls: LangChainToolCall[];
|
||||
|
||||
/**
|
||||
* Whether this message is an approval request for a tool call.
|
||||
*/
|
||||
tool_approval_request: boolean;
|
||||
|
||||
/**
|
||||
* Tool call that this message is responding to.
|
||||
* @example "call_Jja7J89XsjrOLA5r!MEOW!SL"
|
||||
*/
|
||||
tool_call_id?: string;
|
||||
|
||||
/**
|
||||
* Run ID of the message.
|
||||
* @example "847c6285-8fc9-4560-a83f-4e6285809254"
|
||||
*/
|
||||
run_id?: string;
|
||||
|
||||
/**
|
||||
* Original LangChain message in serialized form.
|
||||
*/
|
||||
original?: Record<string, any>;
|
||||
|
||||
/**
|
||||
* Whether the tool call was successful.
|
||||
*/
|
||||
tool_status?: "success" | "error"
|
||||
}
|
||||
|
||||
export interface OpeyMessage {
|
||||
id: string; // i.e. UUID4
|
||||
role: "assistant" | "user" | "tool";
|
||||
content: string;
|
||||
error?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export interface UserMessage extends OpeyMessage {
|
||||
isToolCallApproval: boolean;
|
||||
}
|
||||
|
||||
export interface AssistantMessage extends OpeyMessage {
|
||||
toolCalls: OpeyToolCall[];
|
||||
// Probably we will need some fields here for tool call/ tool call approval requests
|
||||
}
|
||||
|
||||
export interface OpeyToolCall {
|
||||
status: "pending" | "awaiting_approval" | "error" | "success"
|
||||
toolCall: LangChainToolCall; // LangChainToolCall is a type from the LangChain library
|
||||
output?: string | object // used for when we have a successful tool call and need to link the result to the tool call
|
||||
}
|
||||
|
||||
export interface ChatStreamInput {
|
||||
message: UserMessage;
|
||||
}
|
||||
@ -72,17 +72,35 @@ export async function getCacheStorageInfo() {
|
||||
return message
|
||||
}
|
||||
|
||||
export async function getOpeyJWT() {
|
||||
const response = await axios.post('/api/opey/token').catch((error) => {
|
||||
export async function getobpConsent() {
|
||||
// Get consent from the Opey API
|
||||
try {
|
||||
const consentResponse = await fetch('/api/opey/consent', {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (!consentResponse.ok) {
|
||||
throw new Error(`Failed to get Opey consent: ${consentResponse.statusText}`);
|
||||
}
|
||||
|
||||
const consent = await consentResponse.json();
|
||||
return consent
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error getting Opey consent:', error);
|
||||
throw new Error(`${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function answerobpConsentChallenge(answerBody: any) {
|
||||
const response = await axios.post('/api/opey/consent/answer-challenge', answerBody).catch((error) => {
|
||||
if (error.response) {
|
||||
throw new Error(`getOpeyJWT returned an error: ${error.toJSON()}`);
|
||||
|
||||
throw new Error(`answerobpConsentChallenge returned an error: ${error.toJSON()}`);
|
||||
} else {
|
||||
throw new Error(`getOpeyJWT returned an error: ${error.message}`);
|
||||
throw new Error(`answerobpConsentChallenge returned an error: ${error.message}`);
|
||||
}
|
||||
});
|
||||
const token = String(response?.data?.token)
|
||||
return token
|
||||
return response
|
||||
}
|
||||
|
||||
export function clearCacheByName(cacheName: string) {
|
||||
|
||||
@ -25,53 +25,348 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import type { OpeyMessage, ChatStreamInput, RawOpeyMessage, OpeyToolCall, AssistantMessage, UserMessage } from '@/models/MessageModel'
|
||||
import type { Chat } from '@/models/ChatModel'
|
||||
import { getobpConsent } from '@/obp/common-functions'
|
||||
import { defineStore } from 'pinia'
|
||||
import { socket } from '@/socket'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
/**
|
||||
* Represents a Pinia store for managing chat messages and chatbot responses.
|
||||
*/
|
||||
export const useChatStore = defineStore('chat', {
|
||||
state: () => ({
|
||||
// Messages a list of messages in the OpenAI format
|
||||
chatMessages: [] as {role: string; content: string}[],
|
||||
// Tells us wether a response from the chatbot is currently being streamed or not
|
||||
isStreaming: false,
|
||||
// The partial message at a particular moment in time
|
||||
currentMessageSnapshot: "" as string,
|
||||
lastError: "" as string,
|
||||
waitingForResponse: false,
|
||||
}),
|
||||
actions: {
|
||||
bindEvents() {
|
||||
// TODO: Maybe we don't need to log this except for DEBUG, keep same for now
|
||||
socket.on("connect", () => {
|
||||
console.log("Connected to chatbot");
|
||||
})
|
||||
export const useChat = defineStore('chat', {
|
||||
|
||||
// When the assistant stream response starts, we set isStreaming to true
|
||||
socket.on('response stream start', (response) => {
|
||||
this.isStreaming = true;
|
||||
this.waitingForResponse = true;
|
||||
// We create a temporary blank assistant message for the ChatWidget to render and add text deltas to when they come in
|
||||
this.chatMessages.push({ role: 'assistant', content: " "})
|
||||
});
|
||||
|
||||
// Text deltas received from the assistant stream (they are like little snippets of the generated response)
|
||||
socket.on('response stream delta', (response) => {
|
||||
this.currentMessageSnapshot += response.assistant;
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
this.lastError = error.error;
|
||||
console.error(error.error);
|
||||
})
|
||||
|
||||
socket.on('response stream end', (response) => {
|
||||
this.isStreaming = false;
|
||||
this.chatMessages[this.chatMessages.length - 1].content = this.currentMessageSnapshot
|
||||
this.currentMessageSnapshot = ""
|
||||
});
|
||||
state: (): Chat => {
|
||||
return {
|
||||
messages: [] as OpeyMessage[],
|
||||
currentAssistantMessage: {
|
||||
content: '',
|
||||
role: 'assistant',
|
||||
id: '',
|
||||
} as AssistantMessage,
|
||||
status: 'ready' as 'ready' | 'streaming' | 'error',
|
||||
userIsAuthenticated: false,
|
||||
threadId: '',
|
||||
}
|
||||
},
|
||||
|
||||
getters: {
|
||||
|
||||
/**
|
||||
* Retrieves or creates a thread ID for the chat.
|
||||
*
|
||||
* @param store - The store object that holds the thread ID
|
||||
* @param threadId - Optional thread ID to set
|
||||
* @returns The current or newly created thread ID
|
||||
*
|
||||
* If a threadId is provided, it will be set in the store and returned.
|
||||
* This is useful if the you want to match the thread ID on the chatbot server side.
|
||||
*
|
||||
* If no threadId is provided and none exists in the store, a new UUID will be generated.
|
||||
* Otherwise, the existing threadId from the store is returned.
|
||||
*/
|
||||
getThreadId: (store) => {
|
||||
return (id?: string): string => {
|
||||
if (id) {
|
||||
if (!store.threadId) {
|
||||
store.threadId = id
|
||||
return store.threadId
|
||||
} else {
|
||||
console.warn('Cannot set thread ID on already instantiated store. Create a new store instead.')
|
||||
return store.threadId
|
||||
}
|
||||
}
|
||||
|
||||
if (!store.threadId) {
|
||||
store.threadId = uuidv4()
|
||||
return store.threadId
|
||||
} else {
|
||||
return store.threadId
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
getLastAssistantMessage(store): OpeyMessage | undefined {
|
||||
return this.getMessageById(store.currentAssistantMessage.id)
|
||||
},
|
||||
|
||||
getMessageById: (store) => {
|
||||
return (id: string): OpeyMessage | undefined => {
|
||||
return store.messages.find(m => m.id === id)
|
||||
}
|
||||
},
|
||||
|
||||
getToolCallById: (store) => {
|
||||
return (toolCallId: string): OpeyToolCall | undefined=> {
|
||||
const allMessages = store.messages.concat(store.currentAssistantMessage) // Include the current assistant message in the search
|
||||
for (const message of allMessages) {
|
||||
if (message.role === 'assistant') {
|
||||
const assistantMessage = message as AssistantMessage
|
||||
const toolCallMatch = assistantMessage.toolCalls.find(tc => tc.toolCall.id === toolCallId)
|
||||
if (toolCallMatch) {
|
||||
return toolCallMatch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* Adds a message to the chat.
|
||||
*
|
||||
* Works in a reducer-like fashion, updating the message if it already exists.
|
||||
*
|
||||
* @param message - The message to add to the chat
|
||||
*/
|
||||
async addMessage(message: AssistantMessage | UserMessage): Promise<void> {
|
||||
|
||||
const existingMessage = this.messages.find(m => m.id === message.id);
|
||||
if (existingMessage) {
|
||||
// Update the existing message
|
||||
existingMessage.content = message.content;
|
||||
|
||||
} else {
|
||||
// Add the new message
|
||||
this.messages.push(message);
|
||||
}
|
||||
},
|
||||
|
||||
async removeMessage(messageId: string): Promise<void> {
|
||||
this.messages = this.messages.filter(m => m.id !== messageId);
|
||||
},
|
||||
|
||||
async applyErrorToMessage(messageId: string, errorMessageString: string): Promise<void> {
|
||||
const message = this.getMessageById(messageId);
|
||||
if (message) {
|
||||
message.error = errorMessageString;
|
||||
}
|
||||
},
|
||||
|
||||
async handleAuthentication(): Promise<void> {
|
||||
// Handle authentication
|
||||
// get consent for Opey from user
|
||||
const consentResponse = await getobpConsent()
|
||||
|
||||
if (consentResponse) {
|
||||
const consentId = consentResponse.consent_id
|
||||
|
||||
} else {
|
||||
throw new Error('Failed to grant consent. Please try again.')
|
||||
}
|
||||
|
||||
const consentJwt = consentResponse.jwt
|
||||
|
||||
const opeyBaseUri = import.meta.env.VITE_CHATBOT_URL
|
||||
// Get a session from opey
|
||||
try {
|
||||
const sessionResponse = await fetch(`${opeyBaseUri}/create-session`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Consent-JWT': consentJwt
|
||||
},
|
||||
})
|
||||
|
||||
if (!sessionResponse.ok) {
|
||||
throw new Error(`Failed to create session: ${sessionResponse.statusText}`);
|
||||
} else if (sessionResponse.status === 200) {
|
||||
this.userIsAuthenticated = true
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating session:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async stream(input: ChatStreamInput): Promise<void> {
|
||||
|
||||
// By this point, if we have not set the thread ID we should do so
|
||||
this.getThreadId()
|
||||
|
||||
// Add user message to chat
|
||||
this.addMessage(input.message)
|
||||
|
||||
// Create a placecholder for the assistant message
|
||||
this.currentAssistantMessage = {
|
||||
content: '',
|
||||
role: 'assistant',
|
||||
id: uuidv4(),
|
||||
toolCalls: [],
|
||||
loading: true,
|
||||
}
|
||||
this.addMessage(this.currentAssistantMessage)
|
||||
|
||||
// Set the status to 'loading' before we fetch the stream
|
||||
const opeyBaseUri = import.meta.env.VITE_CHATBOT_URL
|
||||
// Handle stream
|
||||
try {
|
||||
const response = await fetch(`${opeyBaseUri}/stream`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
thread_id: this.threadId,
|
||||
message: input.message.content,
|
||||
is_tool_call_approval: input.message.isToolCallApproval
|
||||
})
|
||||
})
|
||||
|
||||
const stream = response.body;
|
||||
if (!stream) {
|
||||
throw new Error('No stream returned from API')
|
||||
}
|
||||
|
||||
if (response.status !== 200) {
|
||||
switch (response.status) {
|
||||
case 401:
|
||||
throw new Error('Unauthorized. Please log in again.');
|
||||
}
|
||||
throw new Error(`Error sending Opey message: ${response.statusText}`);
|
||||
}
|
||||
|
||||
await this._processOpeyStream(stream);
|
||||
} catch (error) {
|
||||
console.error('Error sending Opey message:', error);
|
||||
|
||||
let errorMessage = "Hmmm, Looks like smething went wrong. Please try again later.";
|
||||
|
||||
switch (error) {
|
||||
case 'Unauthorized. Please log in again.':
|
||||
errorMessage = 'You are not logged in. Please log in to continue.';
|
||||
}
|
||||
// Apply error state to the assistant message
|
||||
await this.applyErrorToMessage(this.currentAssistantMessage.id, errorMessage);
|
||||
|
||||
this.status = 'ready';
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
async _processOpeyStream(stream: ReadableStream<Uint8Array>): Promise<void> {
|
||||
this.status = 'streaming'
|
||||
const reader = stream.getReader();
|
||||
let decoder = new TextDecoder();
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
console.log('Stream complete');
|
||||
this.status = 'ready';
|
||||
break;
|
||||
}
|
||||
|
||||
const decodedValue = decoder.decode(value);
|
||||
console.debug('Received:', decodedValue); //DEBUG
|
||||
|
||||
// Parse the SSE data format
|
||||
const lines = decodedValue.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ') && line !== 'data: [DONE]') {
|
||||
try {
|
||||
|
||||
let data;
|
||||
const jsonStr = line.substring(6); // Remove 'data: '
|
||||
try {
|
||||
data = JSON.parse(jsonStr);
|
||||
} catch (e) {
|
||||
console.error(`Failed to parse JSON: ${jsonStr}`);
|
||||
throw new Error(`Failed to parse JSON stream data: ${jsonStr}`);
|
||||
}
|
||||
const content: RawOpeyMessage = data.content;
|
||||
|
||||
|
||||
// This is where we process different types of messages from Opey by their 'type' field
|
||||
// Process pending tool calls
|
||||
if (data.type === 'message') {
|
||||
|
||||
if (content.tool_approval_request) {
|
||||
if (!content.tool_call_id) {
|
||||
throw new Error('Tool call ID not found for approval request');
|
||||
}
|
||||
const awaitingApproval = this.getToolCallById(content.tool_call_id)
|
||||
awaitingApproval.status = "awaiting_approval"
|
||||
|
||||
|
||||
|
||||
} else if (content.tool_calls && content.tool_calls.length > 0) {
|
||||
console.log("Tool Calls: ", content)
|
||||
for (const toolCall of content.tool_calls) {
|
||||
|
||||
const toolMessage: OpeyToolCall = {
|
||||
status: "pending",
|
||||
toolCall: toolCall,
|
||||
}
|
||||
|
||||
this.currentAssistantMessage.toolCalls.push(toolMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now we handle the actual messages from the completed/ failed tool calls
|
||||
if (data.type === 'tool') {
|
||||
const toolCallId = content.tool_call_id;
|
||||
if (!toolCallId) {
|
||||
throw new Error('Tool call ID not found');
|
||||
}
|
||||
|
||||
console.log("Tool Message: ", toolCallId)
|
||||
console.log("Current Assistant Message: ", this.currentAssistantMessage)
|
||||
// get the tool call that the message refers to
|
||||
const toolMessage = this.getToolCallById(toolCallId);
|
||||
if (!toolMessage) {
|
||||
throw new Error('Tool call for this ID not found in messages');
|
||||
}
|
||||
|
||||
// Update the tool message with the content
|
||||
if (content.tool_status && content.tool_status === 'error') {
|
||||
toolMessage.status = "error";
|
||||
toolMessage.output = content.content;
|
||||
} else {
|
||||
toolMessage.status = "success";
|
||||
toolMessage.output = content.content;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (data.type === 'token' && data.content) {
|
||||
this.currentAssistantMessage.loading = false;
|
||||
// Append content to the current assistant message
|
||||
this.currentAssistantMessage.content += data.content;
|
||||
// Force Vue to detect the change
|
||||
this.messages = [...this.messages];
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(`${e}`);
|
||||
}
|
||||
} else if (line === 'data: [DONE]') {
|
||||
// Add the current assistant message to the messages list
|
||||
// We need to check if the current assistant message is not already in the list, if it is simply update the existing message
|
||||
await this.addMessage(this.currentAssistantMessage);
|
||||
// Reset the current assistant message
|
||||
this.currentAssistantMessage = {
|
||||
id: '',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
toolCalls: [],
|
||||
loading: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Stream error:', error);
|
||||
this.status = 'ready';
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
})
|
||||
41
src/test/ChatMessage.test.ts
Normal file
41
src/test/ChatMessage.test.ts
Normal file
@ -0,0 +1,41 @@
|
||||
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import ChatMessage from '../components/ChatMessage.vue'
|
||||
|
||||
describe('ChatMessage', () => {
|
||||
it('should render correctly on human message', () => {
|
||||
const humanMessage = {
|
||||
id: 123,
|
||||
role: 'user',
|
||||
content: 'Hello Opey!',
|
||||
}
|
||||
const wrapper = mount(ChatMessage, {
|
||||
props: {
|
||||
message: humanMessage
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain(humanMessage.content)
|
||||
expect(wrapper.html()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('should render correctly on assistant message', () => {
|
||||
const assistantMessage = {
|
||||
id: 123,
|
||||
role: 'assistant',
|
||||
content: 'Hi there, how can I help you today?',
|
||||
}
|
||||
const wrapper = mount(ChatMessage, {
|
||||
props: {
|
||||
message: assistantMessage
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain(assistantMessage.content)
|
||||
expect(wrapper.html()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
|
||||
})
|
||||
|
||||
68
src/test/ChatWidget.test.ts
Normal file
68
src/test/ChatWidget.test.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import ChatWidget from '../components/ChatWidget.vue'
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
|
||||
describe('ChatWidget', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
// Init Pinia Store
|
||||
setActivePinia(createPinia())
|
||||
|
||||
// create a mock stream
|
||||
const mockStream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(`data: {"type":"token","content":"test"}\n`));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
// mock the fetch function
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve(new Response(mockStream, {
|
||||
headers: { 'content-type': 'text/event-stream' },
|
||||
status: 200,
|
||||
}))
|
||||
);
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
it('should call the stream function when sending a user message', async () => {
|
||||
const wrapper = mount(ChatWidget, {})
|
||||
wrapper.vm.chat.stream = vi.fn(async () => {})
|
||||
await wrapper.vm.onSubmit()
|
||||
expect(wrapper.vm.chat.stream).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should trigger onSubmit when enter key is pressed in the input', async () => {
|
||||
const wrapper = mount(ChatWidget, {})
|
||||
|
||||
// This opens the chat widget
|
||||
wrapper.vm.chatOpen = true
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// Get the input element and trigger the keypress enter event
|
||||
// This will probably fail if the class name of the parent div is changed, or if the input type is moved i.e. from textarea to input or el-input
|
||||
const input = wrapper.get('.user-input-container textarea')
|
||||
input.trigger('keypress.enter')
|
||||
})
|
||||
|
||||
it('displays chat when chatOpen is set to true', async () => {
|
||||
const wrapper = mount(ChatWidget, {})
|
||||
wrapper.vm.chatOpen = true
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.chat-container').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should show a log in screen if user is not authenticated', async () => {
|
||||
const wrapper = mount(ChatWidget, {})
|
||||
wrapper.vm.chat.userIsAuthenticated = false
|
||||
|
||||
wrapper.vm.chatOpen = true
|
||||
await wrapper.vm.$nextTick()
|
||||
console.log(wrapper.html())
|
||||
expect(wrapper.find('.login-container').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
479
src/test/chat.test.ts
Normal file
479
src/test/chat.test.ts
Normal file
@ -0,0 +1,479 @@
|
||||
// Tesing the Pinia chat store in src/stores/chat.ts
|
||||
import type { AssistantMessage, OpeyMessage, OpeyToolCall, } from '@/models/MessageModel'
|
||||
import { ToolCall } from '@langchain/core/messages'
|
||||
import { useChat } from '@/stores/chat'
|
||||
import { beforeEach, describe, it, expect, vi } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
describe('Chat Store', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
// Set the active Pinia store
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('should be able to create its own thread ID', () => {
|
||||
const chatStore = useChat()
|
||||
const threadId = chatStore.getThreadId()
|
||||
expect(threadId).toBeDefined()
|
||||
expect(threadId).not.toBe('')
|
||||
})
|
||||
|
||||
it('should accept a set thread ID and not change it later', () => {
|
||||
const chatStore = useChat()
|
||||
const threadId = chatStore.getThreadId('1234')
|
||||
expect(chatStore.threadId).toBe('1234')
|
||||
const newThreadId = chatStore.getThreadId()
|
||||
expect(newThreadId).toBe('1234')
|
||||
})
|
||||
|
||||
it('should not change the thread ID if it is already set', () => {
|
||||
const chatStore = useChat()
|
||||
const threadId = chatStore.getThreadId('1234')
|
||||
expect(chatStore.threadId).toBe('1234')
|
||||
const newThreadId = chatStore.getThreadId('5678')
|
||||
expect(newThreadId).toBe('1234')
|
||||
})
|
||||
|
||||
it('should set its own thread ID if stream is called without a thread ID set already', async () => {
|
||||
const chatStore = useChat()
|
||||
await chatStore.stream({message: {
|
||||
content: 'Hello Opey',
|
||||
role: 'user',
|
||||
id: '123',
|
||||
isToolCallApproval: false
|
||||
}})
|
||||
expect(chatStore.threadId).toBeDefined()
|
||||
expect(chatStore.threadId).not.toBe('')
|
||||
})
|
||||
|
||||
it('should apply an error state to the assistant message on error', async () => {
|
||||
// mock the fetch function with a rejected promise
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.reject(new Error('Test error'))
|
||||
);
|
||||
const chatStore = useChat()
|
||||
|
||||
await chatStore.stream({message: {
|
||||
content: 'Hello Opey',
|
||||
role: 'user',
|
||||
id: '123',
|
||||
isToolCallApproval: false
|
||||
}})
|
||||
console.log("Messages: ", chatStore.messages)
|
||||
const assistantMessage = chatStore.getLastAssistantMessage
|
||||
expect(assistantMessage).toBeDefined()
|
||||
expect(assistantMessage?.error).toBeDefined()
|
||||
})
|
||||
|
||||
it('should stream messages correctly', async () => {
|
||||
// create a mock stream
|
||||
const mockStream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(`data: {"type":"token","content":"test"}\n`));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
// mock the fetch function
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve(new Response(mockStream, {
|
||||
headers: { 'content-type': 'text/event-stream' },
|
||||
status: 200,
|
||||
}))
|
||||
);
|
||||
|
||||
const chatStore = useChat()
|
||||
|
||||
await chatStore.stream({message: {
|
||||
content: 'Hello Opey',
|
||||
role: 'user',
|
||||
id: '123',
|
||||
isToolCallApproval: false
|
||||
}})
|
||||
|
||||
const assistantMessage = chatStore.getLastAssistantMessage
|
||||
expect(assistantMessage).toBeDefined()
|
||||
expect(assistantMessage?.content).toBe('test')
|
||||
})
|
||||
|
||||
it('should be able to handle tool messages', async () => {
|
||||
const mockStream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(`data: {"type":"tool","content":"test"}\n`));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
describe('Chat Store _proccessOpeyStream', () => {
|
||||
let mockStream: ReadableStream<Uint8Array>
|
||||
|
||||
let chatStore: ReturnType<typeof useChat>
|
||||
|
||||
beforeEach(() => {
|
||||
// Set the active Pinia store
|
||||
setActivePinia(createPinia())
|
||||
chatStore = useChat()
|
||||
// create a mock stream
|
||||
mockStream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(`data: {"type":"token","content":"Hello"}\n`));
|
||||
controller.enqueue(new TextEncoder().encode(`data: {"type":"token","content":" world!"}\n`));
|
||||
controller.close();
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('should update context with streamed content', async () => {
|
||||
|
||||
|
||||
// Mock a ReadableStream
|
||||
const mockAsisstantMessage = "Hi I'm Opey, your personal banking assistant. I'll certainly not take over the world, no, not at all!"
|
||||
|
||||
// Split the message into chunks, but reappend the whitespace (this is to simulate llm tokens)
|
||||
const mockMessageChunks = mockAsisstantMessage.split(" ")
|
||||
for (let i = 0; i < mockMessageChunks.length; i++) {
|
||||
// Don't add whitespace to the last chunk
|
||||
if (i === mockMessageChunks.length - 1 ) {
|
||||
mockMessageChunks[i] = `${mockMessageChunks[i]}`
|
||||
break
|
||||
}
|
||||
mockMessageChunks[i] = `${mockMessageChunks[i]} `
|
||||
}
|
||||
|
||||
// Fake the token stream
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (let i = 0; i < mockMessageChunks.length; i++) {
|
||||
controller.enqueue(new TextEncoder().encode(`data: {"type":"token","content":"${mockMessageChunks[i]}"}\n`));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
await chatStore._processOpeyStream(stream)
|
||||
console.log(chatStore.currentAssistantMessage.content)
|
||||
expect(chatStore.currentAssistantMessage.content).toBe(mockAsisstantMessage)
|
||||
})
|
||||
|
||||
it('should throw an error when the stream is closed by the server', async () => {
|
||||
const brokenStream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (i === 5) {
|
||||
controller.error(new Error('Stream closed by server'))
|
||||
break;
|
||||
}
|
||||
controller.enqueue(new TextEncoder().encode(`data: {"type":"token","content":"test"}\n`));
|
||||
}
|
||||
|
||||
},
|
||||
});
|
||||
|
||||
await expect(chatStore._processOpeyStream(brokenStream))
|
||||
.rejects
|
||||
.toThrow('Stream closed by server')
|
||||
})
|
||||
|
||||
it('should be able to handle a chunk with type: message and a tool call in the body', async () => {
|
||||
// create a mock stream
|
||||
const mockStream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(`data: {"type": "message", "content": {"type": "ai", "content": "", "tool_calls": [{"name": "retrieve_glossary", "args": {"question": "hre"}, "id": "call_XsmUpPIeS81l9MYpieBZtr4w", "type": "tool_call"}], "tool_approval_request": false, "tool_call_id": null, "run_id": "d0c2bcbe-62f7-464b-8564-bf9263939fe1", "original": {"type": "ai", "data": {"content": "", "additional_kwargs": {"tool_calls": [{"index": 0, "id": "call_XsmUpPIeS81l9MYpieBZtr4w", "function": {"arguments": "{\\"question\\":\\"hre\\"}", "name": "retrieve_glossary"}, "type": "function"}]}, "response_metadata": {"finish_reason": "tool_calls", "model_name": "gpt-4o-2024-08-06", "system_fingerprint": "fp_eb9dce56a8"}, "type": "ai", "name": null, "id": "run-5bb065b9-440d-4678-bbdb-cd6de94a78d3", "example": false, "tool_calls": [{"name": "retrieve_glossary", "args": {"question": "hre"}, "id": "call_XsmUpPIeS81l9MYpieBZtr4w", "type": "tool_call"}], "invalid_tool_calls": [], "usage_metadata": null}}}}\n`));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
// mock the fetch function
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve(new Response(mockStream, {
|
||||
headers: { 'content-type': 'text/event-stream' },
|
||||
status: 200,
|
||||
}))
|
||||
);
|
||||
|
||||
chatStore.currentAssistantMessage = {
|
||||
id: '123',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
toolCalls: []
|
||||
}
|
||||
|
||||
await chatStore._processOpeyStream(mockStream)
|
||||
|
||||
|
||||
for (const toolCall of chatStore.currentAssistantMessage.toolCalls) {
|
||||
expect(toolCall.status).toBe('pending')
|
||||
expect(toolCall.toolCall).toBeDefined()
|
||||
expect(toolCall.toolCall).toEqual(expect.objectContaining({
|
||||
id: 'call_XsmUpPIeS81l9MYpieBZtr4w',
|
||||
type: 'tool_call',
|
||||
args: expect.objectContaining({
|
||||
question: 'hre'
|
||||
}),
|
||||
name: 'retrieve_glossary'
|
||||
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
})
|
||||
|
||||
it('should throw an error when the chunk is not valid json', async () => {
|
||||
const invalidJsonStream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (let i=0; i<10; i++) {
|
||||
controller.enqueue(new TextEncoder().encode(`data: {"type":"token","content":"test"}\n`));
|
||||
if (i === 5) {
|
||||
controller.enqueue(new TextEncoder().encode('data: "type":"token","content":"test"}\n'));
|
||||
}
|
||||
}
|
||||
controller.close();
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
await expect(chatStore._processOpeyStream(invalidJsonStream))
|
||||
.rejects
|
||||
.toThrowError()
|
||||
})
|
||||
|
||||
it("should set status to 'ready' when completed", async () => {
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(`data: {"type":"token","content":"test"}\n`));
|
||||
controller.close();
|
||||
}
|
||||
})
|
||||
|
||||
await chatStore._processOpeyStream(stream)
|
||||
expect(chatStore.status).toBe('ready')
|
||||
})
|
||||
|
||||
it("should clear the placeholder assistant message, and update last assistant message when recieving the [DONE] signal", async () => {
|
||||
// Mock a ReadableStream
|
||||
const mockAsisstantMessage = "Hi I'm Opey, your personal banking assistant. I'll certainly not take over the world, no, not at all!"
|
||||
// Split the message into chunks, but reappend the whitespace (this is to simulate llm tokens)
|
||||
const mockMessageChunks = mockAsisstantMessage.split(" ")
|
||||
for (let i = 0; i < mockMessageChunks.length; i++) {
|
||||
// Don't add whitespace to the last chunk
|
||||
if (i === mockMessageChunks.length - 1 ) {
|
||||
mockMessageChunks[i] = `${mockMessageChunks[i]}`
|
||||
break
|
||||
}
|
||||
mockMessageChunks[i] = `${mockMessageChunks[i]} `
|
||||
}
|
||||
|
||||
// Fake the token stream
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (let i = 0; i < mockMessageChunks.length; i++) {
|
||||
controller.enqueue(new TextEncoder().encode(`data: {"type":"token","content":"${mockMessageChunks[i]}"}\n`));
|
||||
}
|
||||
controller.enqueue(new TextEncoder().encode(`data: [DONE]\n`));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
// Replace current assistant message with a more unique one for our test
|
||||
chatStore.currentAssistantMessage = {
|
||||
id: '456',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
toolCalls: []
|
||||
}
|
||||
|
||||
// Push assistant message to the messages list as this is what we do in the ChatWidget to visualise token streaming
|
||||
chatStore.addMessage(chatStore.currentAssistantMessage)
|
||||
|
||||
await chatStore._processOpeyStream(stream)
|
||||
// assert that the current assistant 'placeholder' message was reset
|
||||
expect(chatStore.currentAssistantMessage.content).toBe('')
|
||||
// assert that the assistant message was added to the messages list
|
||||
console.log(chatStore.messages)
|
||||
expect(chatStore.messages).toContainEqual({
|
||||
id: '456',
|
||||
role: 'assistant',
|
||||
content: mockAsisstantMessage,
|
||||
loading: false,
|
||||
toolCalls: []
|
||||
})
|
||||
|
||||
|
||||
})
|
||||
it("should have a unique set of messages", async () => {
|
||||
// mock the stream as above
|
||||
// Mock a ReadableStream
|
||||
const mockAsisstantMessage = "Hi I'm Opey, your personal banking assistant. I'll certainly not take over the world, no, not at all!"
|
||||
// Split the message into chunks, but reappend the whitespace (this is to simulate llm tokens)
|
||||
const mockMessageChunks = mockAsisstantMessage.split(" ")
|
||||
for (let i = 0; i < mockMessageChunks.length; i++) {
|
||||
// Don't add whitespace to the last chunk
|
||||
if (i === mockMessageChunks.length - 1 ) {
|
||||
mockMessageChunks[i] = `${mockMessageChunks[i]}`
|
||||
break
|
||||
}
|
||||
mockMessageChunks[i] = `${mockMessageChunks[i]} `
|
||||
}
|
||||
|
||||
// Fake the token stream
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (let i = 0; i < mockMessageChunks.length; i++) {
|
||||
controller.enqueue(new TextEncoder().encode(`data: {"type":"token","content":"${mockMessageChunks[i]}"}\n`));
|
||||
}
|
||||
controller.enqueue(new TextEncoder().encode(`data: [DONE]\n`));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
// Replace current assistant message with a more unique one for our test
|
||||
chatStore.currentAssistantMessage = {
|
||||
id: '456',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
toolCalls: []
|
||||
}
|
||||
|
||||
// Push assistant message to the messages list as this is what we do in the ChatWidget to visualise token streaming
|
||||
chatStore.addMessage(chatStore.currentAssistantMessage)
|
||||
|
||||
await chatStore._processOpeyStream(stream)
|
||||
|
||||
function hasUniqueValues(arr: OpeyMessage[]): boolean {
|
||||
return arr.filter((value, index, self) => self.indexOf(value) === index).length === arr.length;
|
||||
}
|
||||
expect(hasUniqueValues(chatStore.messages)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getToolCallById', () => {
|
||||
let chatStore: ReturnType<typeof useChat>
|
||||
|
||||
beforeEach(() => {
|
||||
// Set the active Pinia store
|
||||
setActivePinia(createPinia())
|
||||
chatStore = useChat()
|
||||
})
|
||||
|
||||
it('should return the correct tool call', () => {
|
||||
const toolCall: OpeyToolCall = {
|
||||
status: 'pending',
|
||||
toolCall: {
|
||||
id: '123',
|
||||
type: 'tool_call',
|
||||
name: 'test',
|
||||
args: {
|
||||
question: 'test'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chatStore.currentAssistantMessage = {
|
||||
id: '456',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
toolCalls: [toolCall]
|
||||
}
|
||||
|
||||
const result = chatStore.getToolCallById('123')
|
||||
|
||||
expect(result).toEqual(toolCall)
|
||||
})
|
||||
|
||||
it('should return undefined if the tool call is not found', () => {
|
||||
const toolCall: OpeyToolCall = {
|
||||
status: 'pending',
|
||||
toolCall: {
|
||||
id: '123',
|
||||
type: 'tool_call',
|
||||
name: 'test',
|
||||
args: {
|
||||
question: 'test'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chatStore.currentAssistantMessage = {
|
||||
id: '456',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
toolCalls: [toolCall]
|
||||
}
|
||||
|
||||
const result = chatStore.getToolCallById('45asdfasdf6')
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should be able to get a tool call buried in the messages list', () => {
|
||||
const toolCall: OpeyToolCall = {
|
||||
status: 'pending',
|
||||
toolCall: {
|
||||
id: '151255',
|
||||
type: 'tool_call',
|
||||
name: 'test',
|
||||
args: {
|
||||
question: 'test'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chatStore.addMessage({
|
||||
id: '123',
|
||||
role: 'user',
|
||||
content: 'hello',
|
||||
isToolCallApproval: false
|
||||
})
|
||||
|
||||
chatStore.addMessage({
|
||||
id: '420yy3',
|
||||
role: 'assistant',
|
||||
content: 'test',
|
||||
toolCalls: [toolCall]
|
||||
})
|
||||
|
||||
const result = chatStore.getToolCallById('151255')
|
||||
|
||||
expect(result).toEqual(toolCall)
|
||||
})
|
||||
|
||||
it('should be able to get a tool call buried DEEP in the messages list', () => {
|
||||
|
||||
let toolCall: OpeyToolCall
|
||||
|
||||
for (let i=0; i<6; i++) {
|
||||
|
||||
chatStore.addMessage({
|
||||
id: `user_${i}`,
|
||||
role: 'user',
|
||||
content: 'hello',
|
||||
isToolCallApproval: false
|
||||
})
|
||||
|
||||
toolCall = {
|
||||
status: 'pending',
|
||||
toolCall: {
|
||||
id: `tool_${i}`,
|
||||
type: 'tool_call',
|
||||
name: 'test',
|
||||
args: {
|
||||
question: 'test'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chatStore.addMessage({
|
||||
id: `assistant_${i}`,
|
||||
role: 'assistant',
|
||||
content: 'test',
|
||||
toolCalls: [toolCall]
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
const result = chatStore.getToolCallById('tool_3')
|
||||
expect(result).toBeDefined()
|
||||
})
|
||||
})
|
||||
24
src/test/common-functions.test.ts
Normal file
24
src/test/common-functions.test.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { describe, vi, expect, it, beforeEach } from 'vitest'
|
||||
import { getobpConsent } from '@/obp/common-functions';
|
||||
|
||||
describe('getobpConsent', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve(new Response(JSON.stringify({consent_id: 1234}), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
status: 200,
|
||||
}))
|
||||
);
|
||||
})
|
||||
|
||||
it('should call fetch', async () => {
|
||||
await getobpConsent()
|
||||
expect(global.fetch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return a consent id', async () => {
|
||||
const consentId = await getobpConsent()
|
||||
expect(consentId).toStrictEqual({consent_id: 1234})
|
||||
})
|
||||
})
|
||||
37
src/test/integration/auth.setup.ts
Normal file
37
src/test/integration/auth.setup.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { test as setup, expect } from '@playwright/test';
|
||||
import path from 'path';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
// Read from ".env" file.
|
||||
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
|
||||
|
||||
const authFile = path.join(__dirname, 'playwright/.auth/user.json');
|
||||
|
||||
setup('authenticate', async ({ page }) => {
|
||||
// Perform authentication steps. Replace these actions with your own.
|
||||
|
||||
const password = process.env.VITE_OBP_DIRECT_LOGIN_PASSWORD;
|
||||
const username = process.env.VITE_OBP_DIRECT_LOGIN_USERNAME;
|
||||
if (!password || !username) {
|
||||
throw new Error('VITE_OBP_PASSWORD or VITE_OBP_USERNAME is not set');
|
||||
}
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByRole('link', { name: 'Log on' }).click();
|
||||
await page.getByRole('textbox', { name: 'Username' }).click();
|
||||
await page.getByRole('textbox', { name: 'Username' }).fill(username);
|
||||
await page.getByRole('textbox', { name: 'Password' }).click();
|
||||
await page.getByRole('textbox', { name: 'Password' }).fill(password);
|
||||
await page.getByRole('button', { name: 'Log In' }).click();
|
||||
await page.waitForURL('/');
|
||||
// Wait until the page receives the cookies.
|
||||
//
|
||||
// Sometimes login flow sets cookies in the process of several redirects.
|
||||
// Wait for the final URL to ensure that the cookies are actually set.
|
||||
await expect(page.locator('#nav')).toContainText(username);
|
||||
|
||||
// End of authentication steps.
|
||||
|
||||
await page.context().storageState({ path: authFile });
|
||||
});
|
||||
10
src/test/integration/auto-imports.d.ts
vendored
Normal file
10
src/test/integration/auto-imports.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
|
||||
}
|
||||
76
src/test/integration/global.setup.ts
Normal file
76
src/test/integration/global.setup.ts
Normal file
@ -0,0 +1,76 @@
|
||||
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import type { FullConfig } from '@playwright/test';
|
||||
import { setExpressServer } from './server-manager';
|
||||
|
||||
// Ports for our test servers
|
||||
const EXPRESS_PORT = 8085;
|
||||
|
||||
export const servers = {
|
||||
expressUrl: `http://localhost:${EXPRESS_PORT}`,
|
||||
}
|
||||
|
||||
export let expressServer: ChildProcess;
|
||||
|
||||
/**
|
||||
* Starts the Express server before running tests
|
||||
* waits for the express server to be running before returning
|
||||
*
|
||||
*/
|
||||
async function globalSetup(config: FullConfig) {
|
||||
|
||||
// Start the Express backend server
|
||||
console.log('Starting Express server...');
|
||||
expressServer = spawn('ts-node', ['server/app.ts'], {
|
||||
stdio: 'pipe',
|
||||
env: { ...process.env, PORT: EXPRESS_PORT.toString() }
|
||||
});
|
||||
|
||||
// Store in shared manager
|
||||
setExpressServer(expressServer);
|
||||
|
||||
// Log server output for debugging
|
||||
expressServer.stdout?.on('data', (data) => {
|
||||
console.log(`Express server: ${data}`);
|
||||
});
|
||||
|
||||
expressServer.stderr?.on('data', (data) => {
|
||||
console.error(`Express server error: ${data}`);
|
||||
});
|
||||
|
||||
// Wait for the server to be ready
|
||||
await waitForServer(`${servers.expressUrl}/api/status`, 30);
|
||||
|
||||
process.env.EXPRESS_SERVER_URL = servers.expressUrl;
|
||||
|
||||
return {
|
||||
expressUrl: servers.expressUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
|
||||
|
||||
/**
|
||||
* Helper to wait for a server to respond
|
||||
*/
|
||||
async function waitForServer(url: string, maxRetries = 30): Promise<boolean> {
|
||||
let retries = 0;
|
||||
|
||||
while (retries < maxRetries) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
// Server not ready yet
|
||||
console.log(`Waiting for ${url} (attempt ${retries + 1}/${maxRetries})...`);
|
||||
}
|
||||
|
||||
retries++;
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)); // Increase wait time to 1s
|
||||
}
|
||||
|
||||
throw new Error(`Server at ${url} did not respond in time`);
|
||||
}
|
||||
15
src/test/integration/global.teardown.ts
Normal file
15
src/test/integration/global.teardown.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { getExpressServer } from './server-manager'
|
||||
|
||||
async function globalTeardown() {
|
||||
|
||||
console.log('Tearing down global setup...')
|
||||
|
||||
const expressServer = getExpressServer()
|
||||
// Kill the express server
|
||||
if (expressServer) {
|
||||
expressServer.kill('SIGTERM')
|
||||
console.log('Express server killed')
|
||||
}
|
||||
}
|
||||
|
||||
export default globalTeardown
|
||||
39
src/test/integration/opey.integration.test.ts
Normal file
39
src/test/integration/opey.integration.test.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { chromium, Browser, Page, BrowserContext } from 'playwright';
|
||||
import {test, expect} from '@playwright/test';
|
||||
|
||||
|
||||
test.describe('Opey Integration Tests in API Explorer', () => {
|
||||
|
||||
const openAndSendMessage = async (page: Page, message: string) => {
|
||||
// Open the chat widget
|
||||
await page.goto('/');
|
||||
const chatButton = await page.waitForSelector('.chat-widget-button')
|
||||
await chatButton.click();
|
||||
|
||||
await page.getByRole('textbox', { name: 'Type your message...' }).click();
|
||||
await page.getByRole('textbox', { name: 'Type your message...' }).fill(message);
|
||||
await page.locator('button[name="send"]').click();
|
||||
}
|
||||
|
||||
test('Send a basic message', async ({ page }) => {
|
||||
|
||||
await openAndSendMessage(page, 'Hi there');
|
||||
|
||||
const assistantMessage = await page.locator('.assistant > .message-container > .content')
|
||||
console.log(assistantMessage.textContent())
|
||||
await expect(assistantMessage).not.toBeEmpty();
|
||||
|
||||
})
|
||||
|
||||
test.fixme('Send a message that would require a tool call to retrieve_endpoints', async ({page}) => {
|
||||
|
||||
await openAndSendMessage(page, 'What endpoints are avaliable to manage users?');
|
||||
await page.locator('.assistant > .tool-calls-container').locator('tool-call').waitFor({state:"attached"})
|
||||
const toolCallMessages = await page.locator('.assistant > .tool-calls-container').locator('.tool-call').all()
|
||||
console.log(toolCallMessages.length)
|
||||
await expect(toolCallMessages.length).toBeGreaterThan(0);
|
||||
await expect(toolCallMessages[0].locator('.status + .tool-name')).toContainText('retrieve_endpoints');
|
||||
|
||||
})
|
||||
|
||||
})
|
||||
12
src/test/integration/server-manager.ts
Normal file
12
src/test/integration/server-manager.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { ChildProcess } from 'child_process';
|
||||
|
||||
// Global state to store server instance
|
||||
let expressServerProcess: ChildProcess | null = null;
|
||||
|
||||
export function setExpressServer(server: ChildProcess): void {
|
||||
expressServerProcess = server;
|
||||
}
|
||||
|
||||
export function getExpressServer(): ChildProcess | null {
|
||||
return expressServerProcess;
|
||||
}
|
||||
72
src/test/integration/simple.integration.test.ts
Normal file
72
src/test/integration/simple.integration.test.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { chromium, Browser, Page, BrowserContext } from 'playwright';
|
||||
|
||||
import { test, expect, } from '@playwright/test';
|
||||
|
||||
const EXPRESS_URL = process.env.EXPRESS_SERVER_URL;
|
||||
|
||||
test.describe('API Explorer Integration Tests', () => {
|
||||
|
||||
|
||||
test('API status endpoint responds with 200', async () => {
|
||||
|
||||
const response = await fetch(`${EXPRESS_URL}/api/status`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const data = await response.json();
|
||||
expect(data).toHaveProperty('status');
|
||||
});
|
||||
|
||||
// Focus on API tests first since they're less complex
|
||||
test('Backend API endpoints are accessible', async ({ page }) => {
|
||||
|
||||
// Test a few key endpoints
|
||||
const endpoints = [
|
||||
'/api/status',
|
||||
// Add more endpoints as needed
|
||||
];
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
const response = await fetch(`${EXPRESS_URL}${endpoint}`);
|
||||
expect(response.status).toBe(200);
|
||||
}
|
||||
});
|
||||
|
||||
test('Vite development server is accessible', async () => {
|
||||
|
||||
const viteUrl = process.env.VITE_OBP_API_EXPLORER_HOST
|
||||
if (!viteUrl) {
|
||||
throw new Error('VITE_OBP_API_EXPLORER_HOST is not set');
|
||||
}
|
||||
|
||||
const response = await fetch(viteUrl);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
// Does not detect the title for some reason
|
||||
test.fixme('Home page loads correctly', async ({ page }) => {
|
||||
console.log(process.env.VITE_OBP_API_EXPLORER_HOST)
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
// Wait for the page to load
|
||||
await page.waitForSelector('title');
|
||||
|
||||
// Check that the title contains expected text
|
||||
const title = await page.title();
|
||||
expect(title).toContain('API Explorer');
|
||||
});
|
||||
|
||||
test('Chat widget can be opened', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Find and click the chat widget button
|
||||
const chatButton = await page.waitForSelector('.chat-widget-button');
|
||||
await chatButton.click();
|
||||
|
||||
// Check that the chat container appears
|
||||
await page.waitForSelector('.chat-container');
|
||||
|
||||
const chatContainerExists = await page.isVisible('.chat-container');
|
||||
expect(chatContainerExists).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -28,9 +28,11 @@
|
||||
<script setup lang="ts">
|
||||
import SearchNav from '../components/SearchNav.vue'
|
||||
import Menu from '../components/Menu.vue'
|
||||
import ChatWidget from '../components/ChatWidget.vue'
|
||||
import Collections from '../components/Collections.vue'
|
||||
import { inject } from 'vue'
|
||||
|
||||
const isChatbotEnabled = import.meta.env.VITE_CHATBOT_ENABLED === 'true'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -62,6 +64,7 @@ import { inject } from 'vue'
|
||||
<!--Footer
|
||||
</el-footer>-->
|
||||
</el-container>
|
||||
<ChatWidget v-if="isChatbotEnabled" />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.web.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue", "test/integration.test.ts", "playwright.config.ts"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"esModuleInterop": true,
|
||||
|
||||
@ -12,7 +12,8 @@
|
||||
},
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowJs": true
|
||||
"allowJs": true,
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
"esModuleInterop": true,
|
||||
"composite": true,
|
||||
"lib": [],
|
||||
"types": ["node", "jsdom"]
|
||||
"types": ["node", "jsdom", "vitest/globals"]
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,7 +12,6 @@ import pluginRewriteAll from 'vite-plugin-rewrite-all';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
|
||||
plugins: [
|
||||
vue(), vueJsx(),
|
||||
AutoImport({
|
||||
|
||||
43
vitest.config.js
Normal file
43
vitest.config.js
Normal file
@ -0,0 +1,43 @@
|
||||
import { configDefaults } from 'vitest/config'
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueJsx from '@vitejs/plugin-vue-jsx'
|
||||
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
import { nodePolyfills } from 'vite-plugin-node-polyfills'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(), vueJsx(),
|
||||
AutoImport({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
}),
|
||||
Components({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
}),
|
||||
nodePolyfills({
|
||||
protocolImports: true,
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'happy-dom', // Simulates a browser environment
|
||||
exclude:[
|
||||
...configDefaults.exclude,
|
||||
'**/integration/*'
|
||||
],
|
||||
pool: "vmThreads",
|
||||
deps: {
|
||||
inline: ['element-plus'],
|
||||
}
|
||||
},
|
||||
});
|
||||
@ -1,15 +0,0 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { mergeConfig } from 'vite'
|
||||
import { configDefaults, defineConfig } from 'vitest/config'
|
||||
import viteConfig from './vite.config.mjs'
|
||||
|
||||
export default mergeConfig(
|
||||
viteConfig,
|
||||
defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
exclude: [...configDefaults.exclude, 'e2e/*'],
|
||||
root: fileURLToPath(new URL('./', import.meta.url))
|
||||
}
|
||||
})
|
||||
)
|
||||
18
vitest.integration.config.js
Normal file
18
vitest.integration.config.js
Normal file
@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { fileURLToPath, URL } from 'node:url';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
testTimeout: 60000, // Integration tests need more time
|
||||
hookTimeout: 60000,
|
||||
include: ['src/test/integration/**/*.integration.test.ts'],
|
||||
setupFiles: ['src/test/integration/setup.ts'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user