adjust frontend to new backend
This commit is contained in:
25
frontend/openapitools.json
Normal file
25
frontend/openapitools.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json",
|
||||
"spaces": 2,
|
||||
"generator-cli": {
|
||||
"version": "6.6.0",
|
||||
"generators": {
|
||||
"typescript": {
|
||||
"generatorName": "typescript",
|
||||
"output": "../oas/",
|
||||
"glob": "../swagger.json",
|
||||
"removeOperationIdPrefix": true,
|
||||
"templateDir": "../templates/",
|
||||
"skipValidateSpec": true,
|
||||
"additionalProperties": {
|
||||
"npmName": "ai-oas",
|
||||
"npmVersion": "1.0.0",
|
||||
"withInterfaces": true,
|
||||
"platform": "browser",
|
||||
"framework": "fetch-api",
|
||||
"supportsES6": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2600
frontend/package-lock.json
generated
2600
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,18 +4,26 @@
|
||||
"private": true,
|
||||
"author": "wea_ondara",
|
||||
"license": "ISC",
|
||||
"type": "module",
|
||||
"node": ">=21.0.0",
|
||||
"scripts": {
|
||||
"setup": "run-s setup:1 setup:2",
|
||||
"setup:1": "npm run build:oas",
|
||||
"setup:2": "npm i --save-optional ../oas",
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"clean": "rm -r ../oas dev/ dist/ 2>/dev/null",
|
||||
"build": "run-s build:oas build:client",
|
||||
"build:oas": "run-s generate:oas build-only:oas",
|
||||
"build:client": "run-p type-check:client build-only:client test:unit",
|
||||
"preview": "vite preview",
|
||||
"test:unit": "vitest",
|
||||
"test:e2e": "start-server-and-test preview http://localhost:4173 'cypress run --e2e'",
|
||||
"test:e2e:dev": "start-server-and-test 'vite dev --port 4173' http://localhost:4173 'cypress open --e2e'",
|
||||
"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/",
|
||||
"clean:src": "find src | grep -E '((\\.d\\.ts)|(\\.js(\\.map)?))$' | xargs rm"
|
||||
"test:unit": "vitest --environment jsdom --root src/ --passWithNoTests",
|
||||
"test:e2e": "start-server-and-test preview :4173 'cypress run --e2e'",
|
||||
"test:e2e:dev": "start-server-and-test 'vite dev --port 4173' :4173 'cypress open --e2e'",
|
||||
"build-only:oas": "cd ../oas && npm i && npm run build",
|
||||
"build-only:client": "vite build --outDir dist",
|
||||
"generate:oas": "openapi-generator-cli generate",
|
||||
"type-check:client": "vue-tsc --noEmit -p tsconfig.vitest.json",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.5.1",
|
||||
@@ -23,13 +31,16 @@
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"bootstrap": "^5.3.3",
|
||||
"bootstrap-vue-next": "^0.16.6",
|
||||
"gravatar": "^1.8.2",
|
||||
"html-entities": "^2.5.2",
|
||||
"idb": "^7.1.1",
|
||||
"mitt": "^3.0.1",
|
||||
"pinia": "^2.1.7",
|
||||
"pinia-class-component": "^0.9.4",
|
||||
"string-similarity-js": "^2.1.4",
|
||||
"vue": "^3.3.4",
|
||||
"vue-class-component": "^8.0.0-rc.1",
|
||||
"vue-facing-decorator": "^3.0.4",
|
||||
"vue-good-table-next": "^0.2.1",
|
||||
"vue-i18n": "^9.10.2",
|
||||
"vue-property-decorator": "^10.0.0-rc.3",
|
||||
@@ -37,28 +48,33 @@
|
||||
"vue3-toastify": "^0.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rushstack/eslint-patch": "^1.8.0",
|
||||
"@tsconfig/node18": "^18.2.2",
|
||||
"@openapitools/openapi-generator-cli": "^2.13.4",
|
||||
"@rushstack/eslint-patch": "^1.10.3",
|
||||
"@tsconfig/node20": "^20.1.4",
|
||||
"@types/bootstrap": "^5.2.10",
|
||||
"@types/gravatar": "^1.8.6",
|
||||
"@types/jsdom": "^21.1.6",
|
||||
"@types/node": "^18.19.26",
|
||||
"@vitejs/plugin-vue": "^4.6.2",
|
||||
"@vue/eslint-config-prettier": "^8.0.0",
|
||||
"@vue/eslint-config-typescript": "^12.0.0",
|
||||
"@vue/test-utils": "^2.4.5",
|
||||
"@vue/tsconfig": "^0.4.0",
|
||||
"cypress": "^13.7.1",
|
||||
"@types/vue-select": "^3.16.8",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"@vue/eslint-config-prettier": "^9.0.0",
|
||||
"@vue/eslint-config-typescript": "^13.0.0",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.5.1",
|
||||
"cypress": "^13.10.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-cypress": "^2.15.1",
|
||||
"eslint-plugin-vue": "^9.23.0",
|
||||
"jsdom": "^22.1.0",
|
||||
"npm-run-all2": "^6.1.2",
|
||||
"eslint-plugin-cypress": "^3.2.0",
|
||||
"eslint-plugin-vue": "^9.26.0",
|
||||
"jsdom": "^24.0.0",
|
||||
"npm-run-all2": "^6.2.0",
|
||||
"prettier": "^3.2.5",
|
||||
"sass": "^1.72.0",
|
||||
"sass": "^1.77.2",
|
||||
"start-server-and-test": "^2.0.3",
|
||||
"typescript": "~5.2.0",
|
||||
"vite": "^4.5.2",
|
||||
"vitest": "^0.34.6",
|
||||
"vue-tsc": "^1.8.27"
|
||||
"typescript": "~5.4.0",
|
||||
"vite": "^5.2.11",
|
||||
"vitest": "^1.6.0",
|
||||
"vue-tsc": "^2.0.19"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"ai-oas": "file:../oas"
|
||||
}
|
||||
}
|
||||
|
||||
26
frontend/src/components/Bootstrap/Alert.vue
Normal file
26
frontend/src/components/Bootstrap/Alert.vue
Normal 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>
|
||||
21
frontend/src/components/CenterOnParent.vue
Normal file
21
frontend/src/components/CenterOnParent.vue
Normal 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>
|
||||
18
frontend/src/components/Loading.vue
Normal file
18
frontend/src/components/Loading.vue
Normal 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>
|
||||
38
frontend/src/components/Spinner.vue
Normal file
38
frontend/src/components/Spinner.vue
Normal 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>
|
||||
80
frontend/src/components/auth/LoginComponent.vue
Normal file
80
frontend/src/components/auth/LoginComponent.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
95
frontend/src/components/navbar/LoggedInComponent.vue
Normal file
95
frontend/src/components/navbar/LoggedInComponent.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
74
frontend/src/components/navbar/ProfilePicture.vue
Normal file
74
frontend/src/components/navbar/ProfilePicture.vue
Normal 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>
|
||||
50
frontend/src/components/navbar/UserAccountDropdown.vue
Normal file
50
frontend/src/components/navbar/UserAccountDropdown.vue
Normal 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>
|
||||
6
frontend/src/composables/emitter.ts
Normal file
6
frontend/src/composables/emitter.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import mitt from 'mitt';
|
||||
|
||||
const emitter = mitt();
|
||||
export default function getEmitter() {
|
||||
return emitter;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export type AiConfiguration = {
|
||||
name: string;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export type ChatMessage = { 'role': string, 'name': string, 'content': string }
|
||||
@@ -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',
|
||||
|
||||
82
frontend/src/stores/AiIConfigurationStore.ts
Normal file
82
frontend/src/stores/AiIConfigurationStore.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
82
frontend/src/stores/AiInstanceStore.ts
Normal file
82
frontend/src/stores/AiInstanceStore.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
38
frontend/src/stores/ApiStore.ts
Normal file
38
frontend/src/stores/ApiStore.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
84
frontend/src/stores/SessionStore.ts
Normal file
84
frontend/src/stores/SessionStore.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
6
frontend/src/stores/events.ts
Normal file
6
frontend/src/stores/events.ts
Normal 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));
|
||||
28
frontend/src/views/auth/LoginView.vue
Normal file
28
frontend/src/views/auth/LoginView.vue
Normal 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>
|
||||
63
frontend/src/views/auth/RegisterView.vue
Normal file
63
frontend/src/views/auth/RegisterView.vue
Normal 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>
|
||||
@@ -4,15 +4,20 @@
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true,
|
||||
"composite": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"moduleResolution": "Node",
|
||||
"lib": [
|
||||
"ES2021",
|
||||
"ESNext",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
]
|
||||
}
|
||||
],
|
||||
"module": "ESNext",
|
||||
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -10,5 +10,9 @@
|
||||
{
|
||||
"path": "./tsconfig.vitest.json"
|
||||
}
|
||||
]
|
||||
],
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true,
|
||||
"module": "NodeNext"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "@tsconfig/node18/tsconfig.json",
|
||||
"extends": "@tsconfig/node20/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
@@ -9,7 +9,11 @@
|
||||
],
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true,
|
||||
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["node"]
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
"extends": "./tsconfig.app.json",
|
||||
"exclude": [],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo",
|
||||
|
||||
"lib": [],
|
||||
"types": ["node", "jsdom"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,11 @@ import {fileURLToPath, URL} from 'node:url';
|
||||
import {defineConfig} from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite';
|
||||
import {ManualChunkMeta} from 'rollup';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(), VueI18nPlugin({compositionOnly: false}),
|
||||
],
|
||||
plugins: [vue(), VueI18nPlugin({compositionOnly: false})],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
@@ -17,9 +16,25 @@ export default defineConfig({
|
||||
build: {
|
||||
manifest: true,
|
||||
emptyOutDir: false,
|
||||
chunkSizeWarningLimit: 10_000_000,
|
||||
rollupOptions: {
|
||||
input: ['./src/main.ts', './index.html'],
|
||||
output: {
|
||||
manualChunks: (id: string, meta: ManualChunkMeta) => {
|
||||
if (id.includes('node_modules')) {
|
||||
return 'vendor';
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
clearScreen: false,
|
||||
server: {
|
||||
proxy: {//for dev
|
||||
'^/api/.*$': {
|
||||
target: 'http://localhost:5172',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user