adjust frontend to new backend

This commit is contained in:
wea_ondara
2024-05-27 19:05:54 +02:00
parent 8b60d023e8
commit 265a5f3063
67 changed files with 5506 additions and 703 deletions

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import {Options, Vue} from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
export type AlertType =
'alert-primary'
| 'alert-secondary'
| 'alert-success'
| 'alert-danger'
| 'alert-warning'
| 'alert-light'
| 'alert-dark';
@Options({
name: 'Alert',
})
export default class Alert extends Vue {
@Prop({default: null})
readonly type: AlertType | null = 'alert-primary';
}
</script>
<template>
<div class="alert" :class="[type]" role="alert">
<slot/>
</div>
</template>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import {Options, Vue} from 'vue-class-component';
@Options({
name: 'CenterOnParent',
components: {},
})
export default class CenterOnParent extends Vue {
}
</script>
<template>
<div class="w-100 h-100 d-flex justify-content-center">
<div class="align-self-center">
<slot></slot>
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import {Options, Vue} from 'vue-class-component';
import CenterOnParent from '@/components/CenterOnParent.vue';
import Spinner from '@/components/Spinner.vue';
@Options({
name: 'Loading',
components: {Spinner, CenterOnParent},
})
export default class Loading extends Vue {
}
</script>
<template>
<CenterOnParent>
<Spinner/>
</CenterOnParent>
</template>

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import {Options, Vue} from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
export type SpinnerType =
'text-primary'
| 'text-secondary'
| 'text-success'
| 'text-danger'
| 'text-warning'
| 'text-info'
| 'text-light'
| 'text-dark';
@Options({
name: 'Spinner',
components: {},
})
export default class Spinner extends Vue {
@Prop({default: null})
private readonly style!: SpinnerType | null;
@Prop({default: null})
private readonly size!: number | null;
private get internalStyle(): any {
return {
height: this.size + 'em',
width: this.size + 'em',
};
}
}
</script>
<template>
<div class="spinner-border" :class="[style]" :style="internalStyle" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</template>

View File

@@ -0,0 +1,80 @@
<script lang="ts">
import {ApiException, LoginRequestVmV1, LoginResponseVmV1} from 'ai-oas';
import {Component, Vue} from 'vue-facing-decorator';
import {SessionStore} from '@/stores/SessionStore';
import {ApiStore} from '@/stores/ApiStore';
@Component({
name: 'LoginComponent',
components: {},
emits: ['loggedIn'],
})
export default class LoginComponent extends Vue {
username: string = '';
password: string = '';
error: string = '';
private readonly apiStore = new ApiStore();
private readonly sessionStore = new SessionStore();
async login(): Promise<void> {
this.error = '';
try {
const res = await this.apiStore.authApi.login(LoginRequestVmV1.fromJson({
username: this.username,
password: this.password,
}));
if (res.success) {
this.sessionStore.setSession(res.user!, res.session!);
this.$emit('loggedIn');
} else {
this.error = 'Something went wrong (unknown reason).';
}
} catch (err) {
this.error = (err as ApiException<LoginResponseVmV1>).body.message ?? '';
}
}
}
</script>
<template>
<div>
<div class="width"></div>
<div v-if="error" class="alert alert-danger" role="alert">
{{ error }}
</div>
<div class="mb-3">
Username
<input type="text" class="form-control" v-model="username"/>
</div>
<div class="mb-3">
Password
<input type="password" class="form-control" v-model="password" @keydown.enter="login"/>
</div>
<div class="float-end mb-3">
<button class="btn btn-primary" @click="login">{{ $t('auth.login') }}</button>
</div>
<div class="clearfix"></div>
</div>
</template>
<style lang="scss" scoped>
@import 'bootstrap/scss/functions';
@import 'bootstrap/scss/variables';
@import 'bootstrap/scss/mixins/breakpoints';
@include media-breakpoint-down(sm) {
.width {
width: calc(100vw - 3em);
transition: width .125s ease-in-out;
}
}
@include media-breakpoint-up(sm) {
.width {
width: 18em;
transition: width .125s ease-in-out;
}
}
</style>

View File

@@ -1,31 +1,21 @@
<script lang="ts">
import {Options, Vue} from 'vue-class-component';
import type {AiInstance} from "@/data/models/AiInstance";
import AiInstanceApi from "@/data/api/AiInstanceApi";
import AiInstanceComponent from "@/components/dashboard/AiInstanceComponent.vue";
import AiInstanceComponent from '@/components/dashboard/AiInstanceComponent.vue';
import Alert from '@/components/Bootstrap/Alert.vue';
import {AiInstanceVmV1} from 'ai-oas';
import {AiInstanceStore} from '@/stores/AiInstanceStore';
import {storeToRefs} from 'pinia';
@Options({
name: 'AiInstanceTabs',
components: {AiInstanceComponent},
components: {Alert, AiInstanceComponent},
})
export default class AiInstanceTabs extends Vue {
readonly aiInstances: AiInstance[] = [];
readonly aiInstanceApi = new AiInstanceApi();
selectedAiInstance: AiInstance | null = null;
readonly aiInstanceStore = new AiInstanceStore();
selectedAiInstance: AiInstanceVmV1 | null = null;
mounted(): void {
this.reload();
}
async reload(): Promise<void> {
const list = await this.aiInstanceApi.fetchAiInstances();
this.aiInstances.splice(0);
this.aiInstances.push(...list);
if (list.length > 0) {
this.selectedAiInstance = list[0];
} else {
this.selectedAiInstance = null;
}
this.aiInstanceStore.loadIfAbsent();
}
}
</script>
@@ -34,19 +24,19 @@ export default class AiInstanceTabs extends Vue {
<div class="d-flex flex-column">
<!-- tabs -->
<div class="d-flex flex-row">
<div class="flex-grow-1">
<div class="flex-grow-1 me-2">
<!-- no ais warning -->
<div v-if="aiInstances.length == 0" class="mt-2 alert alert-warning" role="alert">
<Alert v-if="aiInstanceStore.aiInstances.length == 0" type="alert-warning" class="my-0">
No AIs defined!
</div>
</Alert>
<!-- actual tabs -->
<div class="btn-group">
<button v-for="aiInstance in aiInstances"
<div v-else class="btn-group">
<button v-for="aiInstance in aiInstanceStore.aiInstances"
:key="aiInstance.configuration.name"
class="btn" :class="{
'btn-secondary': aiInstance === selectedAiInstance,
'btn-secondary-outline': aiInstance !== selectedAiInstance
'btn-outline-secondary': aiInstance !== selectedAiInstance
}"
@click="selectedAiInstance = aiInstance">
{{ aiInstance.configuration.name }}
@@ -55,7 +45,7 @@ export default class AiInstanceTabs extends Vue {
</div>
<!-- reload -->
<button class="btn btn-secondary" @click="reload()">
<button class="btn btn-secondary" @click="aiInstanceStore.reload()">
<i class="fa fa-refresh"/>
</button>
</div>

View File

@@ -1,34 +1,34 @@
<script lang="ts">
import {Options, Vue} from 'vue-class-component';
import {Prop} from "vue-property-decorator";
import AiInstanceApi from "@/data/api/AiInstanceApi";
import type {AiInstance} from "@/data/models/AiInstance";
import {toast} from "vue3-toastify";
import type {ChatMessage} from "@/data/models/chat/ChatMessage";
import {BSpinner} from "bootstrap-vue-next";
import {Prop} from 'vue-property-decorator';
import {toast} from 'vue3-toastify';
import {BSpinner} from 'bootstrap-vue-next';
import {AiInstanceVmV1, ChatMessageVmV1} from 'ai-oas';
import {ApiStore} from '@/stores/ApiStore';
import Spinner from '@/components/Spinner.vue';
@Options({
name: 'Chat',
components: {BSpinner},
components: {
Spinner,
},
})
export default class Chat extends Vue {
@Prop({required: true})
readonly aiInstance!: AiInstance;
readonly aiInstanceApi = new AiInstanceApi();
readonly aiInstance!: AiInstanceVmV1;
readonly apiStore = new ApiStore();
user: string = 'alice';
text: string = '';
waiting: boolean = false;
message: ChatMessage | null = null;
message: ChatMessageVmV1 | null = null;
async send(): Promise<void> {
this.waiting = true;
try {
this.message = {'role': 'user', 'name': this.user, 'content': this.text};
this.message = ChatMessageVmV1.fromJson({'role': 'user', 'name': this.user, 'content': this.text});
this.text = '';
const response = await this.aiInstanceApi.chatText(this.aiInstance.configuration.name, this.message);
debugger;
await this.apiStore.aiApi.chatText(this.aiInstance.configuration.id, this.message);
this.aiInstance.messages.push(this.message);
this.aiInstance.messages.push(response);
} catch (e) {
toast.error('Error while chatting: ' + JSON.stringify(e));
} finally {
@@ -65,7 +65,7 @@ export default class Chat extends Vue {
</div>
<div v-if="waiting">
<b>{{ message!.name }}</b>{{ ': ' + message!.content }}
<Spinner/>
<Spinner style="text-secondary" :size="1"/>
</div>
</div>
<div class="input-group">

View File

@@ -1,10 +1,10 @@
<script lang="ts">
import {Options, Vue} from 'vue-class-component';
import {Prop} from "vue-property-decorator";
import type {AiInstance} from "@/data/models/AiInstance";
import {getCurrentInstance} from "vue";
import {toast} from "vue3-toastify";
import AiInstanceApi from "@/data/api/AiInstanceApi";
import {AiInstanceVmV1} from 'ai-oas';
import {ApiStore} from '@/stores/ApiStore';
@Options({
name: 'Discord',
@@ -13,8 +13,8 @@ import AiInstanceApi from "@/data/api/AiInstanceApi";
})
export default class Discord extends Vue {
@Prop({required: true})
readonly aiInstance!: AiInstance;
readonly aiInstanceApi = new AiInstanceApi();
readonly aiInstance!: AiInstanceVmV1;
readonly apiStore = new ApiStore();
get online(): boolean {
return this.aiInstance.discord.online;
@@ -23,22 +23,22 @@ export default class Discord extends Vue {
set online(val: boolean) {
const oldVal = this.online;
this.aiInstance.discord.online = val;
this.aiInstanceApi.discordOnline(this.aiInstance.configuration.name, val).catch(e => {
this.apiStore.aiApi.discordOnline(this.aiInstance.configuration.id, val).catch(e => {
toast.error('Error while set online status: ' + JSON.stringify(e));
this.aiInstance.discord.online = oldVal;
});
}
get react_to_chat(): boolean {
return this.aiInstance.discord.react_to_chat;
get reactToChat(): boolean {
return this.aiInstance.discord.reactToChat;
}
set react_to_chat(val: boolean) {
set reactToChat(val: boolean) {
const oldVal = this.online;
this.aiInstance.discord.react_to_chat = val;
this.aiInstanceApi.discordReactToChat(this.aiInstance.configuration.name, val).catch(e => {
this.aiInstance.discord.reactToChat = val;
this.apiStore.aiApi.discordReactToChat(this.aiInstance.configuration.name, val).catch(e => {
toast.error('Error while setting react_to_chat status: ' + JSON.stringify(e));
this.aiInstance.discord.react_to_chat = oldVal;
this.aiInstance.discord.reactToChat = oldVal;
});
}
@@ -59,7 +59,7 @@ export default class Discord extends Vue {
<label class="form-check-label" :for="uid + '_online'">Online</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" :id="uid + '_reactToChat'" v-model="react_to_chat">
<input class="form-check-input" type="checkbox" :id="uid + '_reactToChat'" v-model="reactToChat">
<label class="form-check-label" :for="uid + '_reactToChat'">React to chat</label>
</div>
</div>

View File

@@ -0,0 +1,95 @@
<script lang="ts">
import {Component, Vue} from 'vue-facing-decorator';
import {SessionStore} from '@/stores/SessionStore';
import {ApiStore} from '@/stores/ApiStore';
import ProfilePicture from '@/components/navbar/ProfilePicture.vue';
@Component({
name: 'LoggedInComponent',
components: {ProfilePicture},
emits: ['actionClicked'],
})
export default class LoggedInComponent extends Vue {
readonly apiStore: ApiStore = new ApiStore();
readonly sessionStore: SessionStore = new SessionStore();
async mounted(): Promise<void> {
await this.sessionStore.loadIfAbsent();
}
onGotoSettings(): void {
this.$router.push('/settings');
this.$emit('actionClicked');
}
onGotoProfile(): void {
this.$router.push('/profile');
this.$emit('actionClicked');
}
async onLogout(): Promise<void> {
const res = await this.apiStore.authApi.logout();
if (res.success) {
this.sessionStore.clear();
}
this.$router.push('/');
this.$emit('actionClicked');
}
}
</script>
<template>
<div class="width"></div>
<li>
<div class="logged-in-header d-flex flex-row">
<div>
<ProfilePicture :size="64"/>
</div>
<div class="ms-3 d-flex flex-column justify-content-center">
<div>{{ sessionStore.user?.displayName ?? sessionStore.user?.name }}</div>
<div><small>{{ sessionStore.user?.email }}</small></div>
</div>
</div>
</li>
<div class="dropdown-divider"></div>
<li>
<a class="dropdown-item c-pointer" @click="onGotoSettings">
<i class="fa fa-cog"></i>
{{ $t('menu.settings') }}
</a>
</li>
<li>
<a class="dropdown-item c-pointer" @click="onGotoProfile">
<i class="fa fa-user"></i>
{{ $t('menu.profile') }}
</a>
</li>
<div class="dropdown-divider"></div>
<li>
<a class="dropdown-item c-pointer" @click="onLogout">
<i class="fa fa-power-off"></i>
{{ $t('auth.logout') }}
</a>
</li>
</template>
<style lang="scss" scoped>
@import 'bootstrap/scss/functions';
@import 'bootstrap/scss/variables';
@import 'bootstrap/scss/mixins/breakpoints';
.logged-in-header {
padding: var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);
}
@include media-breakpoint-down(sm) {
.width {
width: 100%;
}
}
@include media-breakpoint-up(sm) {
.width {
width: 18em;
}
}
</style>

View File

@@ -3,6 +3,7 @@ import {Options, Vue} from 'vue-class-component';
import BootstrapThemeSwitch from '@/components/bootstrapThemeSwitch/BootstrapThemeSwitch.vue';
import LocaleSelector from '@/components/locale/LocaleSelector.vue';
import Logo from '@/components/Logo.vue';
import UserAccountDropdown from '@/components/navbar/UserAccountDropdown.vue';
@Options({
name: 'NavBar',
@@ -10,6 +11,7 @@ import Logo from '@/components/Logo.vue';
BootstrapThemeSwitch,
LocaleSelector,
Logo,
UserAccountDropdown,
},
emits: {
'toggleSidebar': undefined,
@@ -45,6 +47,7 @@ export default class NavBar extends Vue {
<div class="d-flex flex-row align-items-center">
<LocaleSelector class="navbar-locale-select"/>
<BootstrapThemeSwitch class="navbar-theme-switch ms-2"/>
<UserAccountDropdown/>
</div>
</div>
</nav>

View File

@@ -0,0 +1,74 @@
<script lang="ts">
import {Component, Prop, Vue} from 'vue-facing-decorator';
import * as gravatar from 'gravatar';
import {SessionStore} from '@/stores/SessionStore';
@Component({
name: 'ProfilePicture',
})
export default class ProfilePicture extends Vue {
@Prop({default: 64})
readonly size!: number;
private readonly sessionStore = new SessionStore();
private useErrorProfilePictureUrl: boolean = false;
get profilePictureUrl(): string | undefined {
return (this.useErrorProfilePictureUrl ? this.errorProfilePictureUrl : undefined)
?? this.gravatarProfilePictureUrl
?? this.errorProfilePictureUrl;
}
private get gravatarProfilePictureUrl(): string | undefined {
const email = this.sessionStore.user?.email;
return email && gravatar.url(email, {protocol: 'https', s: '' + this.size});
}
private get errorProfilePictureUrl(): string {
//prepare canvas
let canvas = document.createElement('canvas');
canvas.height = this.size;
canvas.width = this.size;
let context = canvas.getContext('2d')!;
//prepare params
const fontSize = this.size / 2;
const char = (this.sessionStore.user?.displayName ?? '?').substring(0, 1);
//prepare colors
const r = (char.codePointAt(0)! * 1361) % 256;
const g = (char.codePointAt(0)! * 1823) % 256;
const b = (char.codePointAt(0)! * 2347) % 256;
const brightness = r * r + g * g + b * b;
const foregroundColor = brightness < 127 * 127 * 3 ? '#fff' : '#000';
const backgroundColor = `rgb(${r},${g},${b})`;
//background
context.fillStyle = backgroundColor;
context.fillRect(0, 0, this.size, this.size);
//text
context.fillStyle = foregroundColor;
context.font = `${fontSize}px sans-serif`;
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText(char, this.size / 2 + .5, this.size / 2 + .5);
//fin
return canvas.toDataURL('png');
}
async mounted(): Promise<void> {
await this.sessionStore.loadIfAbsent();
}
onProfilePictureError(): void {
this.useErrorProfilePictureUrl = true;
}
}
</script>
<template>
<img :src="profilePictureUrl" alt="Profile picture" :width="size" :height="size" class="rounded-circle"
referrerpolicy="no-referrer" @error="onProfilePictureError">
</template>

View File

@@ -0,0 +1,50 @@
<script lang="ts">
import LoggedInComponent from '@/components/navbar/LoggedInComponent.vue';
import LoginComponent from '@/components/auth/LoginComponent.vue';
import {SessionStore} from '@/stores/SessionStore';
import {Component, Vue} from 'vue-facing-decorator';
import {getCurrentInstance} from 'vue';
import ProfilePicture from '@/components/navbar/ProfilePicture.vue';
import {Dropdown} from 'bootstrap';
@Component({
name: 'UserAccountDropdown',
methods: {getCurrentInstance},
components: {
LoggedInComponent,
LoginComponent,
ProfilePicture,
},
})
export default class UserAccountDropdown extends Vue {
readonly sessionStore = new SessionStore();
get uid(): number {
return getCurrentInstance()!.uid;
}
async mounted(): Promise<void> {
await this.sessionStore.loadIfAbsent();
}
closeDropDown(): void {
const el = this.$refs.dropdownTrigger as HTMLElement;
Dropdown.getOrCreateInstance(el)?.hide();
}
}
</script>
<template>
<div class="dropdown">
<div ref="dropdownTrigger" class="m-1 d-flex align-items-center c-pointer" :id="'userdropdown' + uid"
data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false">
<ProfilePicture size="32"/>
</div>
<ul class="dropdown-menu dropdown-menu-end text-small shadow"
:aria-labelledby="'userdropdown' + uid">
<li v-if="!sessionStore.session" style="margin: -.5em 0">
<LoginComponent class="m-3"/>
</li>
<LoggedInComponent v-if="sessionStore.user" @actionClicked="closeDropDown"/>
</ul>
</div>
</template>

View File

@@ -0,0 +1,6 @@
import mitt from 'mitt';
const emitter = mitt();
export default function getEmitter() {
return emitter;
}

View File

@@ -1,45 +0,0 @@
import {handleJsonResponse} from '@/data/api/ApiUtils';
import type {AiInstance} from "@/data/models/AiInstance";
import type {ChatMessage} from "@/data/models/chat/ChatMessage";
export default class AiInstanceApi {
readonly baseurl = 'http://localhost:8877/'
async fetchAiInstances(): Promise<AiInstance[]> {
const res = fetch(this.baseurl + 'api/v1/ai',
{
headers: {'Accept': 'application/json'}
});
return await handleJsonResponse(res);
}
async chatText(aiName: string, message: ChatMessage): Promise<ChatMessage> {
const res = fetch(this.baseurl + 'api/v1/ai/' + aiName + '/chat/text',
{
method: 'POST',
headers: {'Accept': 'application/json', 'Content-Type': 'application/json'},
body: JSON.stringify(message),
});
return await handleJsonResponse(res);
}
async discordOnline(aiName: string, online: boolean): Promise<void> {
const res = fetch(this.baseurl + 'api/v1/ai/' + aiName + '/discord',
{
method: 'PATCH',
headers: {'Accept': 'application/json', 'Content-Type': 'application/json'},
body: JSON.stringify({online: online}),
});
await handleJsonResponse(res);
}
async discordReactToChat(aiName: string, react_to_chat: boolean): Promise<void> {
const res = fetch(this.baseurl + 'api/v1/ai/' + aiName + '/discord',
{
method: 'PATCH',
headers: {'Accept': 'application/json', 'Content-Type': 'application/json'},
body: JSON.stringify({react_to_chat: react_to_chat}),
});
await handleJsonResponse(res);
}
}

View File

@@ -1,22 +0,0 @@
export class ApiError extends Error {
readonly statusCode: number;
constructor(statusCode: number, message?: string) {
super(message);
this.statusCode = statusCode;
}
}
export function handleJsonResponse<T>(res: Promise<Response>): Promise<T> {
return res.then(async e => {
if (e.status === 200) {
const text = await e.text();
if (!text.length) {
return undefined;
} else {
return JSON.parse(text);
}
}
throw new ApiError(e.status, 'Api error ' + e.status + ': ' + await e.text());
});
}

View File

@@ -1,3 +0,0 @@
export type AiConfiguration = {
name: string;
}

View File

@@ -1,11 +0,0 @@
import type {AiConfiguration} from "./AiConfiguration";
import type {ChatMessage} from "@/data/models/chat/ChatMessage";
export type AiInstance = {
configuration: AiConfiguration;
discord: {
online: boolean;
react_to_chat: boolean;
}
messages: ChatMessage[];
}

View File

@@ -1 +0,0 @@
export type ChatMessage = { 'role': string, 'name': string, 'content': string }

View File

@@ -9,6 +9,16 @@ const router = createRouter({
name: 'home',
component: HomeView,
},
{
path: '/register',
name: 'register',
component: () => import('../views/auth/RegisterView.vue'),
},
{
path: '/login',
name: 'login',
component: () => import('../views/auth/LoginView.vue'),
},
{
path: '/about',
name: 'about',

View File

@@ -0,0 +1,82 @@
import {Pinia, Store} from 'pinia-class-component';
import {ApiStore} from '@/stores/ApiStore';
import {AiConfigurationVmV1} from 'ai-oas';
@Store({
id: 'AiConfigurationStore',
name: 'AiConfigurationStore',
})
export class AiConfigurationStore extends Pinia {
private get apiStore() {
return new ApiStore();
}
//data
private _loadingPromise: Promise<AiConfigurationVmV1[]> | null = null;
private _aiConfigurations: AiConfigurationVmV1[] = [];
//getter
get loading(): boolean {
return this._loadingPromise !== null;
}
get aiConfigurations(): AiConfigurationVmV1[] {
return this._aiConfigurations;
}
//actions
clear(): void {
this._aiConfigurations.splice(0);
}
setAis(ais: AiConfigurationVmV1[]): void {
this._aiConfigurations.splice(0);
this._aiConfigurations.push(...ais);
}
addAiConfiguration(ai: AiConfigurationVmV1): void {
this._aiConfigurations.push(ai);
}
updateInstanceAi(ai: AiConfigurationVmV1): void {
const index = this._aiConfigurations.findIndex(e => e.id == ai.id);
if (index >= 0) {
this._aiConfigurations.splice(index, 1, ai);
} else {
this._aiConfigurations.push(ai);
}
}
forgetInstanceAi(ai: AiConfigurationVmV1): void {
const index = this._aiConfigurations.findIndex(e => e.id == ai.id);
if (index >= 0) {
this._aiConfigurations.splice(index, 1);
}
}
async reload(force: boolean = false): Promise<void> {
if (this._loadingPromise) {
await this._loadingPromise;
if (!force) {// when force is true, wait for the current operation to complete, then reload
return;
}
}
try {
this._loadingPromise = this.apiStore.aiConfigurationApi.list();
const ais = await this._loadingPromise;
this.setAis(ais);
} catch (err) {
this.clear();
} finally {
this._loadingPromise = null;
}
}
async loadIfAbsent(): Promise<void> {
if (this._aiConfigurations.length > 0) {
return;
}
return await this.reload();
}
}

View File

@@ -0,0 +1,82 @@
import {Pinia, Store} from 'pinia-class-component';
import {ApiStore} from '@/stores/ApiStore';
import {AiInstanceVmV1} from 'ai-oas';
@Store({
id: 'AiInstanceStore',
name: 'AiInstanceStore',
})
export class AiInstanceStore extends Pinia {
private get apiStore() {
return new ApiStore();
}
//data
private _loadingPromise: Promise<AiInstanceVmV1[]> | null = null;
private _aiInstances: AiInstanceVmV1[] = [];
//getter
get loading(): boolean {
return this._loadingPromise !== null;
}
get aiInstances(): AiInstanceVmV1[] {
return this._aiInstances;
}
//actions
clear(): void {
this._aiInstances.splice(0);
}
setAis(ais: AiInstanceVmV1[]): void {
this._aiInstances.splice(0);
this._aiInstances.push(...ais);
}
addAiInstance(ai: AiInstanceVmV1): void {
this._aiInstances.push(ai);
}
updateInstanceAi(ai: AiInstanceVmV1): void {
const index = this._aiInstances.findIndex(e => e.configuration.id == ai.configuration.id);
if (index >= 0) {
this._aiInstances.splice(index, 1, ai);
} else {
this._aiInstances.push(ai);
}
}
forgetInstanceAi(ai: AiInstanceVmV1): void {
const index = this._aiInstances.findIndex(e => e.configuration.id == ai.configuration.id);
if (index >= 0) {
this._aiInstances.splice(index, 1);
}
}
async reload(force: boolean = false): Promise<void> {
if (this._loadingPromise) {
await this._loadingPromise;
if (!force) {// when force is true, wait for the current operation to complete, then reload
return;
}
}
try {
this._loadingPromise = this.apiStore.aiApi.list();
const ais = await this._loadingPromise;
this.setAis(ais);
} catch (err) {
this.clear();
} finally {
this._loadingPromise = null;
}
}
async loadIfAbsent(): Promise<void> {
if (this._aiInstances.length > 0) {
return;
}
return await this.reload();
}
}

View File

@@ -0,0 +1,38 @@
import {Pinia, Store} from 'pinia-class-component';
import {AiConfigurationApi, AiApi, AuthApi, createConfiguration, HttpMethod, RequestContext, UsersApi} from 'ai-oas';
@Store({
id: 'ApiStore',
name: 'ApiStore',
})
export class ApiStore extends Pinia {
private readonly _config = createConfiguration({
baseServer: {
makeRequestContext(endpoint: string, httpMethod: HttpMethod): RequestContext {
return new RequestContext(endpoint, httpMethod);
},
},
});
//api
private readonly _authApi = new AuthApi(this._config);
private readonly _aiApi = new AiApi(this._config);
private readonly _aiConfigurationApi = new AiConfigurationApi(this._config);
private readonly _userApi = new UsersApi(this._config);
get authApi(): AuthApi {
return this._authApi;
}
get aiApi(): AiApi {
return this._aiApi;
}
get aiConfigurationApi(): AiConfigurationApi {
return this._aiConfigurationApi;
}
get userApi(): UsersApi {
return this._userApi;
}
}

View File

@@ -0,0 +1,84 @@
import {Pinia, Store} from 'pinia-class-component';
import {type UserInfoVmV1, type UserSessionInfoVmV1, VerifyResponseVmV1} from 'ai-oas';
import {ApiStore} from '@/stores/ApiStore';
import useEmitter from '@/composables/emitter';
@Store({
id: 'SessionStore',
name: 'SessionStore',
})
export class SessionStore extends Pinia {
private get apiStore() {
return new ApiStore();
}
//data
private _loadedOnce: boolean = false;
private _loadingPromise: Promise<VerifyResponseVmV1> | null = null;
private _user: UserInfoVmV1 | null = null;
private _session: UserSessionInfoVmV1 | null = null;
//getter
get loading(): boolean {
return this._loadingPromise !== null;
}
get isLoggedIn(): boolean {
return !!this.user;
}
get user(): UserInfoVmV1 | null {
return this._user;
}
get session(): UserSessionInfoVmV1 | null {
return this._session;
}
//actions
clear() {
this._user = null;
this._session = null;
this.triggerEvent();
}
setSession(user: UserInfoVmV1, session: UserSessionInfoVmV1) {
this._user = user;
this._session = session;
this.triggerEvent();
}
async reload(force: boolean = false) {
if (this._loadingPromise) {
await this._loadingPromise;
if (!force) {// when force is true, wait for the current operation to complete, then reload
return;
}
}
try {
this._loadingPromise = this.apiStore.authApi.verify();
const res = await this._loadingPromise;
this._user = res.user ?? null;
this._session = res.session ?? null;
this.triggerEvent();
} catch (err) {
this.clear();
} finally {
this._loadingPromise = null;
this._loadedOnce = true;
}
}
async loadIfAbsent(): Promise<void> {
if (this._loadedOnce) {
return;
}
return await this.reload();
}
//
private triggerEvent(): void {
useEmitter().emit('authChanged');
}
}

View File

@@ -0,0 +1,6 @@
import useEmitter from '@/composables/emitter';
import {AiInstanceStore} from '@/stores/AiInstanceStore';
import {AiConfigurationStore} from '@/stores/AiIConfigurationStore';
useEmitter().on('authChanged', () => new AiInstanceStore().reload(true));
useEmitter().on('authChanged', () => new AiConfigurationStore().reload(true));

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import {Component, Vue} from 'vue-facing-decorator';
import LoginComponent from '@/components/auth/LoginComponent.vue';
import CenterOnParent from '@/components/CenterOnParent.vue';
@Component({
name: 'LoginView',
components: {
CenterOnParent,
LoginComponent
},
})
export default class LoginView extends Vue {
async onLoggedIn(): Promise<void> {
this.$router.push('/');
}
}
</script>
<template>
<center-on-parent>
<div class="card">
<div class="card-body">
<LoginComponent @loggedIn="onLoggedIn"/>
</div>
</div>
</center-on-parent>
</template>

View File

@@ -0,0 +1,63 @@
<script lang="ts">
import {ApiException, RegisterRequestVmV1, RegisterResponseVmV1} from 'ai-oas';
import {Component, Vue} from 'vue-facing-decorator';
import {ApiStore} from '@/stores/ApiStore';
import {SessionStore} from '@/stores/SessionStore';
@Component({
name: 'RegisterView',
components: {},
})
export default class RegisterView extends Vue {
username: string = '';
password: string = '';
error: string = '';
private readonly apiStore = new ApiStore();
private readonly sessionStore = new SessionStore();
async register(): Promise<void> {
this.error = '';
try {
const res = await this.apiStore.authApi.register(RegisterRequestVmV1.fromJson({ username: this.username, password: this.password }));
if (res.success) {
this.sessionStore.setSession(res.user!, res.session!);
this.$router.push('/');
} else {
this.error = 'Something went wrong (unknown reason).';//TODO translate
}
} catch (err) {
this.error = (err as ApiException<RegisterResponseVmV1>).body.message ?? '';
}
}
}
</script>
<template>
<div class="w-100 h-100 d-flex justify-content-center">
<div class="align-self-center">
<div class="card">
<div class="card-body">
<div v-if="error" class="alert alert-danger" role="alert">
{{ error }}
</div>
<div class="mb-3">
Username
<input type="text" class="form-control" v-model="username" />
</div>
<div class="mb-3">
Password
<input type="password" class="form-control" v-model="password" />
</div>
<div class="float-right">
<button class="btn btn-primary" @click="register">{{ $t('auth.register') }}</button>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
</style>