added backend which maintains ai instances;

added frontend as control panel
This commit is contained in:
wea_ondara
2024-04-24 19:40:03 +02:00
parent 9cafd8b1e9
commit 41474d474c
79 changed files with 9022 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
.idea/
frontend/node_modules
frontend/dev-dist
frontend/dist
backend/db.sqlite3
conversations/
models/

5
backend/README.md Normal file
View File

@@ -0,0 +1,5 @@
Run dev env: `./dev.sh`
Migrate `python manage.py migrate`
Make migration `python manage.py makemigrations --name <name> ai`

0
backend/__init__.py Normal file
View File

0
backend/ai/__init__.py Normal file
View File

3
backend/ai/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

51
backend/ai/ais/AiBase.py Normal file
View File

@@ -0,0 +1,51 @@
import datetime
import json
import os
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
class AiBase:
model_id_or_path: str = None
model = None
tokenizer = None
def __init__(self, model_id_or_path: str = None):
self.model_id_or_path = model_id_or_path
print('Loading ' + model_id_or_path)
self.model_id_or_path = model_id_or_path
self.model = AutoModelForCausalLM.from_pretrained(model_id_or_path, torch_dtype='auto', device_map='auto')
self.tokenizer = AutoTokenizer.from_pretrained(model_id_or_path)
# print(self.tokenizer.default_chat_template)
# print(type(self.model))
# print(type(self.tokenizer))
print('Loaded')
def generate(self, messages):
return []
def record_conversation(self, messages, response):
messages = messages + [response]
this_dir = os.path.dirname(os.path.abspath(__file__))
conversations_folder = this_dir + '/../../../conversations'
folder = conversations_folder + '/' + self.model_id_or_path.replace('/', '_')
self._mkdir(conversations_folder)
self._mkdir(folder)
timestamp = datetime.datetime.utcnow().strftime('%Y%m%d%H%M%S')
pickle_filename = folder + '/' + timestamp + '.json'
with open(pickle_filename, 'w') as file:
json.dump(messages, file)
def _mkdir(self, path):
if not os.path.isdir(path):
os.mkdir(path)
def __del__(self):
del self.model
del self.tokenizer
import gc
gc.collect()
torch.cuda.empty_cache()

38
backend/ai/ais/QwenAi.py Normal file
View File

@@ -0,0 +1,38 @@
import torch
from .AiBase import AiBase
class QwenAi(AiBase):
default_device = 'cuda' # the device to load the model onto
default_model_id = 'Qwen/Qwen1.5-1.8B-Chat'
default_instruction = {'role': 'system',
'name': 'system',
'content': 'Your name is "Laura". You are an AI created by Alice.'}
def __init__(self, model_id_or_path=default_model_id):
super().__init__(model_id_or_path)
def generate(self, messages):
try:
# prepare
messages = [m for m in messages if m['role'] != 'system']
input_messages = [self.default_instruction] + messages
# generate
text = self.tokenizer.apply_chat_template(input_messages, tokenize=False, add_generation_prompt=True)
model_inputs = self.tokenizer([text], return_tensors='pt').to(self.default_device)
generated_ids = self.model.generate(model_inputs.input_ids, max_new_tokens=300)
generated_ids = [
output_ids[len(input_ids):] for input_ids, output_ids in zip(model_inputs.input_ids, generated_ids)
]
response = self.tokenizer.batch_decode(generated_ids, skip_special_tokens=True)[0]
# add response and save conversation
response_entry = {'role': 'assistant', 'name': 'assistant', 'content': response}
messages.append(response_entry)
self.record_conversation(input_messages, response_entry)
return messages
finally:
torch.cuda.empty_cache() # clear cache or the gpu mem will be used a lot

View File

6
backend/ai/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AisConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'ai'

8
backend/ai/injector.py Normal file
View File

@@ -0,0 +1,8 @@
from injector import Binder, Module, singleton
from .services.AiService import AiService
class InjectorModule(Module):
def configure(self, binder: Binder) -> None:
binder.bind(AiService, to=AiService(), scope=singleton)

View File

@@ -0,0 +1,22 @@
# Generated by Django 5.0.4 on 2024-04-22 15:06
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='AiConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('model_id_or_path', models.CharField(max_length=255)),
],
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.0.4 on 2024-04-23 17:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='aiconfig',
name='discord_token',
field=models.CharField(default='', max_length=255),
preserve_default=False,
),
]

View File

11
backend/ai/models.py Normal file
View File

@@ -0,0 +1,11 @@
from django.db import models
# Create your models here.
class AiConfig(models.Model):
name = models.CharField(max_length=255)
model_id_or_path = models.CharField(max_length=255)
discord_token = models.CharField(max_length=255)
def __str__(self):
return self.name

19
backend/ai/serializers.py Normal file
View File

@@ -0,0 +1,19 @@
from rest_framework import serializers
from .models import AiConfig
class AiConfigSerializer(serializers.ModelSerializer):
class Meta:
model = AiConfig
fields = ['name', 'model_id_or_path']
class DiscordStatusSerializer(serializers.Serializer):
online = serializers.BooleanField()
react_to_chat = serializers.BooleanField()
class AiInstanceSerializer(serializers.Serializer):
configuration = AiConfigSerializer()
messages = serializers.ListField()
discord = DiscordStatusSerializer()

View File

@@ -0,0 +1,68 @@
from typing import AnyStr
from .DcClient import DcClient
from ..ais.AiBase import AiBase
from ..ais.QwenAi import QwenAi
from ..models import AiConfig
class DiscordStatus:
_instance: any
_client: DcClient
online: bool = False
react_to_chat: bool = False
def __init__(self, instance):
self._instance = instance
self._client = DcClient(instance)
def set_online(self, online: bool):
if online and not self.online:
self._client.connect()
elif not online and self.online:
self._client.disconnect()
else:
return
self.online = online
def set_react_to_chat(self, react_to_chat: bool):
self.react_to_chat = react_to_chat
def __del__(self):
self.set_online(False)
class AiInstance:
_bot: AiBase | None = None
configuration: AiConfig
discord: DiscordStatus
messages = []
def __init__(self, configuration: AiConfig):
print('Initializing AiInstance ' + configuration.name)
self.configuration = configuration
self.discord = DiscordStatus(self)
def endure_loaded(self):
if self._bot is None:
self._bot = QwenAi(self.configuration.model_id_or_path)
def endure_unloaded(self):
if self._bot is not None:
del self._bot
self._bot = None
def chat_text(self, user: AnyStr, text: AnyStr):
self.endure_loaded()
self.messages.append({'role': 'user', 'name': user, 'content': text})
self.messages = self._bot.generate(self.messages)
return self.messages[-1]
def chat_conversation(self, conversation):
self.endure_loaded()
return self._bot.generate(conversation)
def __del__(self):
print('Destructing AiInstance ' + self.configuration.name)
del self._bot
del self.discord

View File

@@ -0,0 +1,35 @@
from .AiInstance import AiInstance
from ..models import AiConfig
class AiService:
instances: [AiInstance] = []
# Singleton
# def __new__(cls):
# if not hasattr(cls, 'instance'):
# cls.instance = super(AiService, cls).__new__(cls)
# return cls.instance
def __init__(self):
print('Initializing AiService')
def get_configurations(self) -> [AiConfig]:
return AiConfig.objects.filter()
def get_instances(self) -> [AiInstance]:
self.__ensure_instances_inited__()
return self.instances
def get_instance(self, name: str) -> AiInstance:
return [i for i in self.get_instances() if i.configuration.name == name][0]
def __ensure_instances_inited__(self):
for config in self.get_configurations():
if len([i for i in self.instances if i.configuration.name == config.name]) == 0:
self.instances.append(AiInstance(config))
def __del__(self):
print('Destructing AiService')
for instance in self.instances:
del instance

View File

@@ -0,0 +1,49 @@
from threading import Thread
from discord import Intents, Client
from . import AiInstance
class DcClient:
_intents: Intents
_discord_client: Client
_ai_instance: AiInstance
def __init__(self, _ai_instance: AiInstance):
self._ai_instance = _ai_instance
self._intents = Intents.default()
self._intents.message_content = True
self._discord_client = Client(intents=self._intents)
@self._discord_client.event
async def on_ready():
print(f'We have logged in as {self._discord_client.user}')
@self._discord_client.event
async def on_message(message):
if not self._ai_instance.discord.react_to_chat:
return None
if message.author == self._discord_client.user:
return
if message.content.isspace():
await message.channel.send('### Empty message')
return
response = self._ai_instance.chat_text(message.author.name, message.content)
if response is not None:
await message.channel.send(response['content'])
def connect(self):
def start():
self._discord_client.run(self._ai_instance.configuration.discord_token)
thread = Thread(target=start)
thread.start()
def disconnect(self):
self._discord_client.close()

3
backend/ai/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

10
backend/ai/urls.py Normal file
View File

@@ -0,0 +1,10 @@
from django.urls import path
from .views import AiConfigApiView, AiApiView, AiDiscordApiView, AiChatTextApiView
urlpatterns = [
path('api/v1/ai/<name>/chat/text', AiChatTextApiView.as_view()),
path('api/v1/ai/<name>/discord', AiDiscordApiView.as_view()),
path('api/v1/ai/config', AiConfigApiView.as_view()),
path('api/v1/ai', AiApiView.as_view()),
]

87
backend/ai/views.py Normal file
View File

@@ -0,0 +1,87 @@
from injector import inject
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from .serializers import AiConfigSerializer, AiInstanceSerializer
from .services.AiService import AiService
class AiApiView(APIView):
# add permission to check if user is authenticated
# permission_classes = [permissions.IsAuthenticated]
ai_service: AiService = None
@inject
def setup(self, request, ai_service: AiService, **kwargs):
super().setup(request)
self.ai_service = ai_service
def get(self, request, *args, **kwargs):
instances = self.ai_service.get_instances()
serializer = AiInstanceSerializer(instances, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
class AiConfigApiView(APIView):
# add permission to check if user is authenticated
# permission_classes = [permissions.IsAuthenticated]
ai_service: AiService = None
@inject
def setup(self, request, ai_service: AiService, **kwargs):
super().setup(request)
self.ai_service = ai_service
def get(self, request, *args, **kwargs):
configurations = self.ai_service.get_configurations()
serializer = AiConfigSerializer(configurations, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
class AiChatTextApiView(APIView):
# add permission to check if user is authenticated
# permission_classes = [permissions.IsAuthenticated]
ai_service: AiService = None
@inject
def setup(self, request, ai_service: AiService, **kwargs):
super().setup(request)
self.ai_service = ai_service
def post(self, request, *args, **kwargs):
name = self.kwargs.get('name')
print("name", name)
ai_instance = self.ai_service.get_instance(name)
if ai_instance is None:
return Response(status=status.HTTP_404_NOT_FOUND)
print(request.data)
response = ai_instance.chat_text(request.data['name'], request.data['content'])
return Response(response, status=status.HTTP_200_OK)
class AiDiscordApiView(APIView):
# add permission to check if user is authenticated
# permission_classes = [permissions.IsAuthenticated]
ai_service: AiService = None
@inject
def setup(self, request, ai_service: AiService, **kwargs):
super().setup(request)
self.ai_service = ai_service
def patch(self, request, *args, **kwargs):
name = self.kwargs.get('name')
ai_instance = self.ai_service.get_instance(name)
if ai_instance is None:
return Response(status=status.HTTP_404_NOT_FOUND)
if 'online' in request.data:
ai_instance.discord.set_online(request.data['online'])
if 'react_to_chat' in request.data:
ai_instance.discord.set_react_to_chat(request.data['react_to_chat'])
return Response(status=status.HTTP_200_OK)

View File

16
backend/backend/asgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
ASGI config for backend project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings')
application = get_asgi_application()

125
backend/backend/settings.py Normal file
View File

@@ -0,0 +1,125 @@
"""
Django settings for backend project.
Generated by 'django-admin startproject' using Django 5.0.4.
For more information on this file, see
https://docs.djangoproject.com/en/5.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.0/ref/settings/
"""
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-a*pam^5+0)v!(#rz^s4cmi13iv==4_!8(v#xk%qnqkv+7z=rai'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django_injector',
'rest_framework',
'ai'
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
# 'django_injector.inject_request_middleware',
]
ROOT_URLCONF = 'backend.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'backend.wsgi.application'
# Database
# https://docs.djangoproject.com/en/5.0/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
# Password validation
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/5.0/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.0/howto/static-files/
STATIC_URL = 'static/'
# Default primary key field type
# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
INJECTOR_MODULES = [
'ai.injector.InjectorModule',
]

23
backend/backend/urls.py Normal file
View File

@@ -0,0 +1,23 @@
"""
URL configuration for backend project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/5.0/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('ai.urls')),
]

16
backend/backend/wsgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
WSGI config for backend project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings')
application = get_wsgi_application()

1
backend/dev.sh Executable file
View File

@@ -0,0 +1 @@
python manage.py runserver 0.0.0.0:8877

22
backend/manage.py Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

68
frontend/README.md Normal file
View File

@@ -0,0 +1,68 @@
# MangaStatus
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
1. Disable the built-in TypeScript Extension
1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette
2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
## Customize configuration
See [Vite Configuration Reference](https://vitejs.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```
### Run Unit Tests with [Vitest](https://vitest.dev/)
```sh
npm run test:unit
```
### Run End-to-End Tests with [Cypress](https://www.cypress.io/)
```sh
npm run test:e2e:dev
```
This runs the end-to-end tests against the Vite development server.
It is much faster than the production build.
But it's still recommended to test the production build with `test:e2e` before deploying (e.g. in CI environments):
```sh
npm run build
npm run test:e2e
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```

View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
specPattern: 'cypress/e2e/**/*.{cy,spec}.{js,jsx,ts,tsx}',
baseUrl: 'http://localhost:4173'
}
})

View File

@@ -0,0 +1,8 @@
// https://on.cypress.io/api
describe('My First Test', () => {
it('visits the app root url', () => {
cy.visit('/')
cy.contains('h1', 'You did it!')
})
})

View File

@@ -0,0 +1,10 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["./**/*", "../support/**/*"],
"compilerOptions": {
"isolatedModules": false,
"target": "es5",
"lib": ["es5", "dom"],
"types": ["cypress"]
}
}

View File

@@ -0,0 +1,5 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

View File

@@ -0,0 +1,39 @@
/// <reference types="cypress" />
// ***********************************************
// This example commands.ts shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
//
// declare global {
// namespace Cypress {
// interface Chainable {
// login(email: string, password: string): Chainable<void>
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
// }
// }
// }
export {}

View File

@@ -0,0 +1,20 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')

1
frontend/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Laura</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

6983
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

64
frontend/package.json Normal file
View File

@@ -0,0 +1,64 @@
{
"name": "frontend",
"version": "1.0.0",
"private": true,
"author": "wea_ondara",
"license": "ISC",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"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"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.5.1",
"@intlify/unplugin-vue-i18n": "^0.11.0",
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.3",
"bootstrap-vue-next": "^0.16.6",
"html-entities": "^2.5.2",
"idb": "^7.1.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-good-table-next": "^0.2.1",
"vue-i18n": "^9.10.2",
"vue-property-decorator": "^10.0.0-rc.3",
"vue-router": "^4.3.0",
"vue3-toastify": "^0.0.4"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.8.0",
"@tsconfig/node18": "^18.2.2",
"@types/bootstrap": "^5.2.10",
"@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",
"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",
"prettier": "^3.2.5",
"sass": "^1.72.0",
"start-server-and-test": "^2.0.3",
"typescript": "~5.2.0",
"vite": "^4.5.2",
"vitest": "^0.34.6",
"vue-tsc": "^1.8.27"
}
}

36
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,36 @@
<script lang="ts">
import {RouterView} from 'vue-router';
import {Options, Vue} from 'vue-class-component';
import DocumentLocaleSetter from '@/components/locale/DocumentLocaleSetter.vue';
import NavBar from '@/components/navbar/NavBar.vue';
import LocaleSaver from '@/components/locale/LocaleSaver.vue';
import SideBar from '@/components/sidebar/SideBar.vue';
@Options({
name: 'App',
components: {
DocumentLocaleSetter,
LocaleSaver,
NavBar,
RouterView,
SideBar,
},
})
export default class App extends Vue {
sidebarToggled = false;
}
</script>
<template>
<div class="h-100 w-100 overflow-hidden">
<LocaleSaver/>
<DocumentLocaleSetter/>
<SideBar ref="sidebar" class="h-100 w-100 overflow-hidden" :toggled="sidebarToggled"
@close="sidebarToggled=false"/>
<div class="d-flex flex-column h-100 w-100 overflow-hidden">
<NavBar @toggleSidebar="sidebarToggled = !sidebarToggled"/>
<RouterView class="flex-grow-1"/>
</div>
</div>
</template>

View File

@@ -0,0 +1,8 @@
//@use 'sass:map';
//@import '~bootstrap/scss/bootstrap';
//@import '~bootstrap/scss/variables';
//@import '~bootstrap/scss/mixins';
.c-pointer {
cursor: pointer;
}

View File

@@ -0,0 +1,11 @@
body {
height: 100vh;
width: 100vw;
overflow: hidden;
}
#app {
height: 100vh;
width: 100vw;
overflow: hidden;
}

View File

@@ -0,0 +1,22 @@
<template>
<div class="logo">
<img src="/favicon.ico" alt="logo" height="24" width="24" class="me-1">
<b>Laura</b>
</div>
</template>
<style>
.logo {
display: flex;
flex-direction: row;
align-items: center;
}
</style>
<script lang="ts">
import {Options, Vue} from 'vue-property-decorator';
@Options({name: 'Logo'})
export default class Logo extends Vue {
}
</script>

View File

@@ -0,0 +1,11 @@
// import {describe, expect, it} from 'vitest';
//
// import {mount} from '@vue/test-utils';
// import HelloWorld from '../HelloWorld.vue';
//
// describe('HelloWorld', () => {
// it('renders properly', () => {
// const wrapper = mount(HelloWorld, {props: {msg: 'Hello Vitest'}});
// expect(wrapper.text()).toContain('Hello Vitest');
// });
// });

View File

@@ -0,0 +1,74 @@
<script lang="ts">
import {Options, Vue} from 'vue-class-component';
import {reactive} from 'vue';
import {Watch} from 'vue-property-decorator';
const sharedDarkMode = reactive({
internalDarkMode: false,
internalLoaded: false,
get darkMode() {
if (!this.internalLoaded) {
this.internalDarkMode = localStorage.getItem('darkmode') === ('' + true);
this.internalLoaded = true;
}
return this.internalDarkMode;
},
set darkMode(value: boolean) {
this.internalDarkMode = value;
localStorage.setItem('darkmode', '' + this.internalDarkMode);
},
});
@Options({
name: 'BootstrapThemeSwitch',
})
export default class BootstrapThemeSwitch extends Vue {
get sharedDarkMode() {
return sharedDarkMode;
}
mounted(): void {
this.onThemeChanged(this.dark);
}
get dark(): boolean {
return this.sharedDarkMode.darkMode;
}
set dark(value: boolean) {
this.sharedDarkMode.darkMode = value;
}
@Watch('dark', {immediate: true})
private onThemeChanged(isDarkMode: boolean): void {
if (isDarkMode) {
document.documentElement.setAttribute('data-bs-theme', 'dark');
} else {
document.documentElement.removeAttribute('data-bs-theme');
}
}
}
</script>
<template>
<div class="form-check form-switch light-dark-switch">
<input class="form-check-input" type="checkbox" role="switch" v-model="dark">
</div>
</template>
<style scoped>
.light-dark-switch .form-check-input {
--bs-form-switch-bg: url(assets/sun.svg) !important;
}
.light-dark-switch .form-check-input:focus {
--bs-form-switch-bg: url(assets/sun_focus.svg) !important;
}
.light-dark-switch .form-check-input:checked,
.light-dark-switch .form-check-input:checked:focus {
--bs-form-switch-bg: url(assets/moon.svg) !important;
}
</style>

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<g transform="scale(0.75 0.75) translate(92 92)" fill="#fff">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512">
<!--! Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. -->
<path d="M144.7 98.7c-21 34.1-33.1 74.3-33.1 117.3c0 98 62.8 181.4 150.4 211.7c-12.4 2.8-25.3 4.3-38.6 4.3C126.6 432 48 353.3 48 256c0-68.9 39.4-128.4 96.8-157.3zm62.1-66C91.1 41.2 0 137.9 0 256C0 379.7 100 480 223.5 480c47.8 0 92-15 128.4-40.6c1.9-1.3 3.7-2.7 5.5-4c4.8-3.6 9.4-7.4 13.9-11.4c2.7-2.4 5.3-4.8 7.9-7.3c5-4.9 6.3-12.5 3.1-18.7s-10.1-9.7-17-8.5c-3.7 .6-7.4 1.2-11.1 1.6c-5 .5-10.1 .9-15.3 1c-1.2 0-2.5 0-3.7 0c-.1 0-.2 0-.3 0c-96.8-.2-175.2-78.9-175.2-176c0-54.8 24.9-103.7 64.1-136c1-.9 2.1-1.7 3.2-2.6c4-3.2 8.2-6.2 12.5-9c3.1-2 6.3-4 9.6-5.8c6.1-3.5 9.2-10.5 7.7-17.3s-7.3-11.9-14.3-12.5c-3.6-.3-7.1-.5-10.7-.6c-2.7-.1-5.5-.1-8.2-.1c-3.3 0-6.5 .1-9.8 .2c-2.3 .1-4.6 .2-6.9 .4z"/>
</svg>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<g transform="scale(0.75 0.75) translate(92 92)" fill="rgba(0,0,0,0.25)">
<svg viewBox="0 0 512 512">
<!--! Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. -->
<path d="M375.7 19.7c-1.5-8-6.9-14.7-14.4-17.8s-16.1-2.2-22.8 2.4L256 61.1 173.5 4.2c-6.7-4.6-15.3-5.5-22.8-2.4s-12.9 9.8-14.4 17.8l-18.1 98.5L19.7 136.3c-8 1.5-14.7 6.9-17.8 14.4s-2.2 16.1 2.4 22.8L61.1 256 4.2 338.5c-4.6 6.7-5.5 15.3-2.4 22.8s9.8 13 17.8 14.4l98.5 18.1 18.1 98.5c1.5 8 6.9 14.7 14.4 17.8s16.1 2.2 22.8-2.4L256 450.9l82.5 56.9c6.7 4.6 15.3 5.5 22.8 2.4s12.9-9.8 14.4-17.8l18.1-98.5 98.5-18.1c8-1.5 14.7-6.9 17.8-14.4s2.2-16.1-2.4-22.8L450.9 256l56.9-82.5c4.6-6.7 5.5-15.3 2.4-22.8s-9.8-12.9-17.8-14.4l-98.5-18.1L375.7 19.7zM269.6 110l65.6-45.2 14.4 78.3c1.8 9.8 9.5 17.5 19.3 19.3l78.3 14.4L402 242.4c-5.7 8.2-5.7 19 0 27.2l45.2 65.6-78.3 14.4c-9.8 1.8-17.5 9.5-19.3 19.3l-14.4 78.3L269.6 402c-8.2-5.7-19-5.7-27.2 0l-65.6 45.2-14.4-78.3c-1.8-9.8-9.5-17.5-19.3-19.3L64.8 335.2 110 269.6c5.7-8.2 5.7-19 0-27.2L64.8 176.8l78.3-14.4c9.8-1.8 17.5-9.5 19.3-19.3l14.4-78.3L242.4 110c8.2 5.7 19 5.7 27.2 0zM256 368a112 112 0 1 0 0-224 112 112 0 1 0 0 224zM192 256a64 64 0 1 1 128 0 64 64 0 1 1 -128 0z"/>
</svg>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<g transform="scale(0.75 0.75) translate(92 92)" fill="#86b7fe">
<svg viewBox="0 0 512 512">
<!--! Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. -->
<path d="M375.7 19.7c-1.5-8-6.9-14.7-14.4-17.8s-16.1-2.2-22.8 2.4L256 61.1 173.5 4.2c-6.7-4.6-15.3-5.5-22.8-2.4s-12.9 9.8-14.4 17.8l-18.1 98.5L19.7 136.3c-8 1.5-14.7 6.9-17.8 14.4s-2.2 16.1 2.4 22.8L61.1 256 4.2 338.5c-4.6 6.7-5.5 15.3-2.4 22.8s9.8 13 17.8 14.4l98.5 18.1 18.1 98.5c1.5 8 6.9 14.7 14.4 17.8s16.1 2.2 22.8-2.4L256 450.9l82.5 56.9c6.7 4.6 15.3 5.5 22.8 2.4s12.9-9.8 14.4-17.8l18.1-98.5 98.5-18.1c8-1.5 14.7-6.9 17.8-14.4s2.2-16.1-2.4-22.8L450.9 256l56.9-82.5c4.6-6.7 5.5-15.3 2.4-22.8s-9.8-12.9-17.8-14.4l-98.5-18.1L375.7 19.7zM269.6 110l65.6-45.2 14.4 78.3c1.8 9.8 9.5 17.5 19.3 19.3l78.3 14.4L402 242.4c-5.7 8.2-5.7 19 0 27.2l45.2 65.6-78.3 14.4c-9.8 1.8-17.5 9.5-19.3 19.3l-14.4 78.3L269.6 402c-8.2-5.7-19-5.7-27.2 0l-65.6 45.2-14.4-78.3c-1.8-9.8-9.5-17.5-19.3-19.3L64.8 335.2 110 269.6c5.7-8.2 5.7-19 0-27.2L64.8 176.8l78.3-14.4c9.8-1.8 17.5-9.5 19.3-19.3l14.4-78.3L242.4 110c8.2 5.7 19 5.7 27.2 0zM256 368a112 112 0 1 0 0-224 112 112 0 1 0 0 224zM192 256a64 64 0 1 1 128 0 64 64 0 1 1 -128 0z"/>
</svg>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import {Options, Vue} from 'vue-class-component';
import {Prop} from "vue-property-decorator";
import type {AiInstance} from "@/data/models/AiInstance";
import Discord from "@/components/dashboard/Discord.vue";
import Chat from "@/components/dashboard/Chat.vue";
@Options({
name: 'AiInstanceComponent',
components: {Chat, Discord},
})
export default class AiInstanceComponent extends Vue {
@Prop({required: true})
readonly aiInstance!: AiInstance;
}
</script>
<template>
<div>
<h3>Name: {{ aiInstance.configuration.name }}</h3>
<div class="d-flex flex-row">
<Chat class="flex-grow-1 me-2" style="width: 66.66%" :ai-instance="aiInstance"/>
<Discord class="flex-grow-1" style="width: 33.33%" :ai-instance="aiInstance"/>
</div>
</div>
</template>

View File

@@ -0,0 +1,66 @@
<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";
@Options({
name: 'AiInstanceTabs',
components: {AiInstanceComponent},
})
export default class AiInstanceTabs extends Vue {
readonly aiInstances: AiInstance[] = [];
readonly aiInstanceApi = new AiInstanceApi();
selectedAiInstance: AiInstance | 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;
}
}
}
</script>
<template>
<div class="d-flex flex-column">
<!-- tabs -->
<div class="d-flex flex-row">
<div class="flex-grow-1">
<!-- no ais warning -->
<div v-if="aiInstances.length == 0" class="mt-2 alert alert-warning" role="alert">
No AIs defined!
</div>
<!-- actual tabs -->
<div class="btn-group">
<button v-for="aiInstance in aiInstances"
:key="aiInstance.configuration.name"
class="btn" :class="{
'btn-secondary': aiInstance === selectedAiInstance,
'btn-secondary-outline': aiInstance !== selectedAiInstance
}"
@click="selectedAiInstance = aiInstance">
{{ aiInstance.configuration.name }}
</button>
</div>
</div>
<!-- reload -->
<button class="btn btn-secondary" @click="reload()">
<i class="fa fa-refresh"/>
</button>
</div>
<!-- panels -->
<AiInstanceComponent v-if="selectedAiInstance" class="flex-grow-1 mt-2" :ai-instance="selectedAiInstance"/>
</div>
</template>

View File

@@ -0,0 +1,78 @@
<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";
@Options({
name: 'Chat',
components: {BSpinner},
})
export default class Chat extends Vue {
@Prop({required: true})
readonly aiInstance!: AiInstance;
readonly aiInstanceApi = new AiInstanceApi();
user: string = 'alice';
text: string = '';
waiting: boolean = false;
message: ChatMessage | null = null;
async send(): Promise<void> {
this.waiting = true;
try {
this.message = {'role': 'user', 'name': this.user, 'content': this.text};
this.text = '';
const response = await this.aiInstanceApi.chatText(this.aiInstance.configuration.name, this.message);
debugger;
this.aiInstance.messages.push(this.message);
this.aiInstance.messages.push(response);
} catch (e) {
toast.error('Error while chatting: ' + JSON.stringify(e));
} finally {
this.waiting = false;
this.message = null;
}
}
}
</script>
<style scoped>
.input-group-vertical :not(:first-child) {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.input-group-vertical :not(:last-child) {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
</style>
<template>
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Chat</h5>
</div>
<div class="card-body">
<div class="input-group-vertical">
<div class="form-control overflow-y-auto">
<div v-if="aiInstance.messages.length === 0">
<i>No conversation history</i>
</div>
<div v-for="message in aiInstance.messages">
<b>{{ message.name }}</b>{{ ': ' + message.content }}
</div>
<div v-if="waiting">
<b>{{ message!.name }}</b>{{ ': ' + message!.content }}
<Spinner/>
</div>
</div>
<div class="input-group">
<input type="text" class="form-control" v-model="user" placeholder="Username" style="max-width: 10em"/>
<input type="text" class="form-control" v-model="text" placeholder="Type message here" @keydown.enter="send"/>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import {Options, Vue} from 'vue-class-component';
import AiInstanceTabs from "@/components/dashboard/AiInstanceTabs.vue";
@Options({
name: 'DashBoard',
components: {AiInstanceTabs},
})
export default class DashBoard extends Vue {
}
</script>
<template>
<AiInstanceTabs class="m-3"/>
</template>

View File

@@ -0,0 +1,67 @@
<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";
@Options({
name: 'Discord',
methods: {getCurrentInstance},
components: {},
})
export default class Discord extends Vue {
@Prop({required: true})
readonly aiInstance!: AiInstance;
readonly aiInstanceApi = new AiInstanceApi();
get online(): boolean {
return this.aiInstance.discord.online;
}
set online(val: boolean) {
const oldVal = this.online;
this.aiInstance.discord.online = val;
this.aiInstanceApi.discordOnline(this.aiInstance.configuration.name, 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;
}
set react_to_chat(val: boolean) {
const oldVal = this.online;
this.aiInstance.discord.react_to_chat = val;
this.aiInstanceApi.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;
});
}
get uid(): string {
return "" + getCurrentInstance()?.uid!;
}
}
</script>
<template>
<div class="card" style="min-width: 15em">
<div class="card-header">
<h5 class="card-title mb-0">Discord</h5>
</div>
<div class="card-body">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" :id="uid + '_online'" v-model="online">
<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">
<label class="form-check-label" :for="uid + '_reactToChat'">React to chat</label>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import {Options, Vue} from 'vue-class-component';
import {Watch} from 'vue-property-decorator';
@Options({name: 'DocumentLocaleSetter'})
export default class DocumentLocaleSetter extends Vue {
mounted(): void {
this.onLocaleChanged(this.$i18n.locale);
}
@Watch('$i18n.locale')
onLocaleChanged(value: string): void {
this.setDocumentLocale(value);
}
setDocumentLocale(locale: string): void {
document.documentElement.setAttribute('lang', locale);
}
}
</script>
<template>
</template>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import {Options, Vue} from 'vue-class-component';
import {Watch} from 'vue-property-decorator';
@Options({name: 'LocaleSaver'})
export default class LocaleSaver extends Vue {
mounted(): void {
const locale = window.localStorage.getItem('locale');
if (locale)
this.$i18n.locale = locale;
}
@Watch('$i18n.locale')
onLocaleChanged(value: string): void {
window.localStorage.setItem('locale', value);
}
}
</script>
<template>
</template>

View File

@@ -0,0 +1,37 @@
<script lang="ts">
import {Options, Vue} from 'vue-class-component';
@Options({name: 'LocaleSelector'})
export default class LocaleSelector extends Vue {
get locale(): string {
return this.$i18n.locale;
}
set locale(value: string) {
this.$i18n.locale = value;
}
get supportedLocales(): { value: string, text: string } [] {
return [
{value: 'en', text: this.$t('locales.en')},
{value: 'de', text: this.$t('locales.de')},
];
}
}
</script>
<template>
<div class="dropdown">
<button aria-expanded="false" class="btn btn-sm btn-outline-secondary" data-bs-toggle="dropdown" type="button">
<i class="fa fa-language"/>
</button>
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-lg-start">
<li v-for="l in supportedLocales" :key="l.value">
<a :class="{active: locale === l.value}" class="dropdown-item" href="#"
@click.prevent="locale = l.value">
{{ l.text }}
</a>
</li>
</ul>
</div>
</template>

View File

@@ -0,0 +1,85 @@
<script lang="ts">
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';
@Options({
name: 'NavBar',
components: {
BootstrapThemeSwitch,
LocaleSelector,
Logo,
},
emits: {
'toggleSidebar': undefined,
},
})
export default class NavBar extends Vue {
}
</script>
<template>
<!-- z-index needed, otherwise shadow not showing -->
<nav class="navbar border-bottom shadow flex-nowrap" style="z-index: 1">
<div>
<div class="navbar-sidebar-toggler mx-1 c-pointer">
<i class="fa fa-bars me-2" @click="$emit('toggleSidebar')"/>
</div>
<div class="navbar-nav flex-row">
<Logo class="navbar-logo"/>
<div class="nav-item">
<RouterLink class="nav-link" aria-current="page" to="/" active-class="active">Laura</RouterLink>
</div>
<div class="nav-item">
<RouterLink class="nav-link" aria-current="page" to="/about" active-class="active">About</RouterLink>
</div>
</div>
</div>
<div>
<div class="d-flex flex-row">
</div>
</div>
<div>
<div class="d-flex flex-row align-items-center">
<LocaleSelector class="navbar-locale-select"/>
<BootstrapThemeSwitch class="navbar-theme-switch ms-2"/>
</div>
</div>
</nav>
</template>
<style lang="scss">
@import 'bootstrap/scss/functions';
@import 'bootstrap/scss/variables';
@import 'bootstrap/scss/mixins/breakpoints';
nav.navbar .navbar-logo,
nav.navbar .nav-item {
margin-inline-end: 1rem;
}
@include media-breakpoint-down(sm) {
nav.navbar {
--bs-navbar-padding-x: 0.5rem;
}
nav.navbar .navbar-nav,
nav.navbar .navbar-locale-select,
nav.navbar .navbar-theme-switch {
display: none;
}
}
@include media-breakpoint-up(sm) {
nav.navbar {
--bs-navbar-padding-x: 1rem;
}
nav.navbar .navbar-sidebar-toggler {
display: none;
}
}
</style>

View File

@@ -0,0 +1,96 @@
<script lang="ts">
import {Options, Vue} from 'vue-class-component';
import SideBarNavItem from '@/components/sidebar/SideBarNavItem.vue';
import SideBarNavLink from '@/components/sidebar/SideBarNavLink.vue';
import {Prop} from 'vue-property-decorator';
import LocaleSelector from '@/components/locale/LocaleSelector.vue';
import BootstrapThemeSwitch from '@/components/bootstrapThemeSwitch/BootstrapThemeSwitch.vue';
import SideBarHead from '@/components/sidebar/SideBarHead.vue';
@Options({
name: 'SideBar',
components: {
BootstrapThemeSwitch,
LocaleSelector,
SideBarHead,
SideBarNavItem,
SideBarNavLink,
},
emits: {
'close': undefined,
},
})
export default class SideBar extends Vue {
@Prop({default: false})
toggled!: boolean;
}
</script>
<template>
<div :class="{ toggled: toggled }" class="sidebar border-right shadow bg-body">
<div class="d-flex flex-column">
<ul class="nav flex-column mb-auto">
<li>
<SideBarHead @close="$emit('close')"/>
</li>
<SideBarNavLink :text="$t('menu.list')" to="/" faIcon="fa-list"
@click="$emit('close')"></SideBarNavLink>
<SideBarNavLink :text="$t('menu.about')" to="/about" faIcon="fa-question"
@click="$emit('close')"></SideBarNavLink>
<li>
<hr class="m-0"/>
</li>
<SideBarNavItem :text="$t('locale')" faIcon="fa-language">
<template #end>
<LocaleSelector class="navbar-locale-select me-1"/>
</template>
</SideBarNavItem>
<SideBarNavItem :text="$t('design')" faIcon="fa-moon">
<template #end>
<BootstrapThemeSwitch class="navbar-theme-switch"/>
</template>
</SideBarNavItem>
</ul>
</div>
</div>
</template>
<style lang="scss">
@import 'bootstrap/scss/functions';
@import 'bootstrap/scss/variables';
@import 'bootstrap/scss/mixins/breakpoints';
$width: 15em;
@include media-breakpoint-down(sm) {
.sidebar {
position: fixed;
top: 0;
left: 0;
width: $width;
max-width: #{'clamp(0em, '}$width#{', 100%)'};
transition: width ease-in-out 0.25s;
overflow-x: hidden;
overflow-y: auto;
z-index: 10000;
height: 100%;
&:not(.toggled) {
width: 0 !important;
}
.sidebar-nav-item {
border-top: 0;
border-bottom: 0;
}
}
}
@include media-breakpoint-up(sm) {
.sidebar {
display: none;
}
}
</style>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import {Options, Vue} from 'vue-class-component';
import Logo from '@/components/Logo.vue';
@Options({
name: 'SideBar',
components: {Logo},
emits: {
'close': undefined,
},
})
export default class SideBar extends Vue {
}
</script>
<template>
<nav class="navbar border-bottom shadow flex-nowrap">
<div class="sidebar-closer mx-1 c-pointer d-flex flex-row align-items-center">
<i class="fa fa-arrow-left me-2" @click="$emit('close')"/>
<!-- 'hack' to ensure this nav is the same height as the navbar -->
<input class="form-control d-inline mx-0 px-0" style="visibility: hidden; width: 0;"/>
<Logo/>
</div>
</nav>
</template>

View File

@@ -0,0 +1,45 @@
<script lang="ts">
import {Options, Vue} from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
@Options({
name: 'SideBarNavItem',
components: {},
})
export default class SideBarNavItem extends Vue {
@Prop({required: false})
faIcon!: string;
@Prop({required: false})
text!: string;
}
</script>
<template>
<li class="nav-item sidebar-nav-item">
<slot>
<div class="d-flex flex-row p-2 align-items-center">
<i :class="[faIcon]" class="sidebar-nav-item-icon text-center fa"></i>
<span class="sidebar-nav-item-text me-auto">
<slot name="text" :text="text">
{{ text }}
</slot>
</span>
<slot name="end"/>
</div>
</slot>
</li>
</template>
<style lang="scss" scoped>
.sidebar-nav-item-text {
opacity: 1;
overflow: hidden;
max-width: 10em;
transition: display ease-in-out 0.25s, opacity ease-in-out 0.25s, max-width ease-in-out 0.25s;
}
.sidebar-nav-item-icon {
width: 2em;
text-align: center;
}
</style>

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import {Options, Vue} from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import SideBarNavItem from '@/components/sidebar/SideBarNavItem.vue';
@Options({
name: 'SideBarNavLink',
components: {SideBarNavItem},
})
export default class SideBarNavLink extends Vue {
@Prop({required: false})
faIcon!: string;
@Prop({required: false})
text!: string;
@Prop({required: true})
to!: string;
}
</script>
<style>
.no-link, .no-link:link, .no-link:hover {
color: inherit;
text-decoration: inherit;
}
</style>
<template>
<SideBarNavItem :fa-icon="faIcon" :text="text">
<template #text="props">
<RouterLink :to="to" class="no-link">
{{ props.text }}
</RouterLink>
</template>
</SideBarNavItem>
</template>

View File

@@ -0,0 +1,45 @@
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

@@ -0,0 +1,22 @@
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

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

View File

@@ -0,0 +1,11 @@
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

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

35
frontend/src/locale/de.ts Normal file
View File

@@ -0,0 +1,35 @@
import {messagesEn} from '@/locale/en';
export const messagesDe = {
design: 'Design',
locale: 'Sprache',
locales: messagesEn.locales,
menu: {
about: 'Über',
list: 'Liste',
},
search: 'Suche',
};
export const datetimeFormatsDe = {
date: {
year: 'numeric',
month: 'numeric',
day: 'numeric',
},
datetime: {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
},
datetimeSeconds: {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
seconds: 'numeric',
},
};

36
frontend/src/locale/en.ts Normal file
View File

@@ -0,0 +1,36 @@
export const messagesEn = {
design: 'Design',
locale: 'Language',
locales: {
'de': 'Deutsch',
'en': 'English',
},
menu: {
about: 'About',
list: 'List',
},
search: 'Search',
};
export const datetimeFormatsEn = {
date: {
year: 'numeric',
month: 'numeric',
day: 'numeric',
},
datetime: {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
},
datetimeSeconds: {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
seconds: 'numeric',
},
};

View File

@@ -0,0 +1,32 @@
import type {I18n} from 'vue-i18n';
import {createI18n as vueCreateI18n} from 'vue-i18n';
import {datetimeFormatsEn, messagesEn} from '@/locale/en';
import {datetimeFormatsDe, messagesDe} from '@/locale/de';
const messages = {
en: messagesEn,
de: messagesDe,
};
const datetimeFormats = {
en: datetimeFormatsEn,
de: datetimeFormatsDe,
};
export function createI18n(): I18n {
let browserLocale = (navigator.language ?? '').toLowerCase();
const match = browserLocale.match(/^([a-z][a-z])/);
if (match) {
browserLocale = match[1];
} else {
browserLocale = 'en';
}
return vueCreateI18n({
locale: browserLocale,
fallbackLocale: 'en',
messages: messages,
//@ts-ignore TS2769 TODO weird typing issue
datetimeFormats: datetimeFormats,
legacy: true,
});
}

24
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,24 @@
import {createApp} from 'vue';
import {createPinia} from 'pinia';
import App from './App.vue';
import router from './router';
import './assets/main.css';
import './assets/bootstrap.extensions.scss';
import 'bootstrap/dist/css/bootstrap.css';
// noinspection ES6UnusedImports
import * as bootstrap from 'bootstrap';
// noinspection ES6UnusedImports
import * as popper from '@popperjs/core';
import '@fortawesome/fontawesome-free/css/all.css';
import {createI18n} from '@/locale/locale';
import 'vue3-toastify/dist/index.css';
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.use(createI18n());
app.mount('#app');

View File

@@ -0,0 +1,23 @@
import {createRouter, createWebHistory} from 'vue-router';
import HomeView from '../views/HomeView.vue';
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
},
{
path: '/about',
name: 'about',
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/AboutView.vue'),
},
],
});
export default router;

View File

@@ -0,0 +1,10 @@
<template>
<div class="p-3 overflow-y-auto">
<div class="mb-3">
<h1>About</h1>
Control panel for Ais
</div>
<div>
</div>
</div>
</template>

View File

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

View File

@@ -0,0 +1,18 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"experimentalDecorators": true,
"composite": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"lib": [
"ES2021",
"DOM",
"DOM.Iterable"
]
}
}

14
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.vitest.json"
}
]
}

View File

@@ -0,0 +1,17 @@
{
"extends": "@tsconfig/node18/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*"
],
"compilerOptions": {
"experimentalDecorators": true,
"composite": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}

View File

@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.app.json",
"exclude": [],
"compilerOptions": {
"types": ["node", "jsdom"]
}
}

25
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,25 @@
import {fileURLToPath, URL} from 'node:url';
import {defineConfig} from 'vite';
import vue from '@vitejs/plugin-vue';
import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(), VueI18nPlugin({compositionOnly: false}),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
build: {
manifest: true,
emptyOutDir: false,
rollupOptions: {
input: ['./src/main.ts', './index.html'],
},
},
clearScreen: false,
});

14
frontend/vitest.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import {fileURLToPath} from 'node:url';
import {configDefaults, defineConfig, mergeConfig} from 'vitest/config';
import viteConfig from './vite.config';
export default mergeConfig(
viteConfig,
defineConfig({
test: {
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/*'],
root: fileURLToPath(new URL('./', import.meta.url)),
},
}),
);