added backend which maintains ai instances;
added frontend as control panel
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal 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
5
backend/README.md
Normal 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
0
backend/__init__.py
Normal file
0
backend/ai/__init__.py
Normal file
0
backend/ai/__init__.py
Normal file
3
backend/ai/admin.py
Normal file
3
backend/ai/admin.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
51
backend/ai/ais/AiBase.py
Normal file
51
backend/ai/ais/AiBase.py
Normal 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
38
backend/ai/ais/QwenAi.py
Normal 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
|
||||||
0
backend/ai/ais/__init__.py
Normal file
0
backend/ai/ais/__init__.py
Normal file
6
backend/ai/apps.py
Normal file
6
backend/ai/apps.py
Normal 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
8
backend/ai/injector.py
Normal 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)
|
||||||
22
backend/ai/migrations/0001_initial.py
Normal file
22
backend/ai/migrations/0001_initial.py
Normal 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
19
backend/ai/migrations/0002_AddDicordToken.py
Normal file
19
backend/ai/migrations/0002_AddDicordToken.py
Normal 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,
|
||||||
|
),
|
||||||
|
]
|
||||||
0
backend/ai/migrations/__init__.py
Normal file
0
backend/ai/migrations/__init__.py
Normal file
11
backend/ai/models.py
Normal file
11
backend/ai/models.py
Normal 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
19
backend/ai/serializers.py
Normal 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()
|
||||||
68
backend/ai/services/AiInstance.py
Normal file
68
backend/ai/services/AiInstance.py
Normal 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
|
||||||
35
backend/ai/services/AiService.py
Normal file
35
backend/ai/services/AiService.py
Normal 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
|
||||||
49
backend/ai/services/DcClient.py
Normal file
49
backend/ai/services/DcClient.py
Normal 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
3
backend/ai/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
10
backend/ai/urls.py
Normal file
10
backend/ai/urls.py
Normal 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
87
backend/ai/views.py
Normal 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)
|
||||||
0
backend/backend/__init__.py
Normal file
0
backend/backend/__init__.py
Normal file
16
backend/backend/asgi.py
Normal file
16
backend/backend/asgi.py
Normal 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
125
backend/backend/settings.py
Normal 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
23
backend/backend/urls.py
Normal 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
16
backend/backend/wsgi.py
Normal 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
1
backend/dev.sh
Executable file
@@ -0,0 +1 @@
|
|||||||
|
python manage.py runserver 0.0.0.0:8877
|
||||||
22
backend/manage.py
Executable file
22
backend/manage.py
Executable 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
68
frontend/README.md
Normal 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
|
||||||
|
```
|
||||||
8
frontend/cypress.config.ts
Normal file
8
frontend/cypress.config.ts
Normal 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'
|
||||||
|
}
|
||||||
|
})
|
||||||
8
frontend/cypress/e2e/example.cy.ts
Normal file
8
frontend/cypress/e2e/example.cy.ts
Normal 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!')
|
||||||
|
})
|
||||||
|
})
|
||||||
10
frontend/cypress/e2e/tsconfig.json
Normal file
10
frontend/cypress/e2e/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"include": ["./**/*", "../support/**/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"isolatedModules": false,
|
||||||
|
"target": "es5",
|
||||||
|
"lib": ["es5", "dom"],
|
||||||
|
"types": ["cypress"]
|
||||||
|
}
|
||||||
|
}
|
||||||
5
frontend/cypress/fixtures/example.json
Normal file
5
frontend/cypress/fixtures/example.json
Normal 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"
|
||||||
|
}
|
||||||
39
frontend/cypress/support/commands.ts
Normal file
39
frontend/cypress/support/commands.ts
Normal 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 {}
|
||||||
20
frontend/cypress/support/e2e.ts
Normal file
20
frontend/cypress/support/e2e.ts
Normal 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
1
frontend/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal 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
6983
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
64
frontend/package.json
Normal file
64
frontend/package.json
Normal 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
36
frontend/src/App.vue
Normal 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>
|
||||||
8
frontend/src/assets/bootstrap.extensions.scss
vendored
Normal file
8
frontend/src/assets/bootstrap.extensions.scss
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
//@use 'sass:map';
|
||||||
|
//@import '~bootstrap/scss/bootstrap';
|
||||||
|
//@import '~bootstrap/scss/variables';
|
||||||
|
//@import '~bootstrap/scss/mixins';
|
||||||
|
|
||||||
|
.c-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
11
frontend/src/assets/main.css
Normal file
11
frontend/src/assets/main.css
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
body {
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
22
frontend/src/components/Logo.vue
Normal file
22
frontend/src/components/Logo.vue
Normal 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>
|
||||||
11
frontend/src/components/__tests__/HelloWorld.spec.ts
Normal file
11
frontend/src/components/__tests__/HelloWorld.spec.ts
Normal 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');
|
||||||
|
// });
|
||||||
|
// });
|
||||||
@@ -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>
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
26
frontend/src/components/dashboard/AiInstanceComponent.vue
Normal file
26
frontend/src/components/dashboard/AiInstanceComponent.vue
Normal 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>
|
||||||
66
frontend/src/components/dashboard/AiInstanceTabs.vue
Normal file
66
frontend/src/components/dashboard/AiInstanceTabs.vue
Normal 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>
|
||||||
78
frontend/src/components/dashboard/Chat.vue
Normal file
78
frontend/src/components/dashboard/Chat.vue
Normal 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>
|
||||||
15
frontend/src/components/dashboard/DashBoard.vue
Normal file
15
frontend/src/components/dashboard/DashBoard.vue
Normal 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>
|
||||||
67
frontend/src/components/dashboard/Discord.vue
Normal file
67
frontend/src/components/dashboard/Discord.vue
Normal 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>
|
||||||
23
frontend/src/components/locale/DocumentLocaleSetter.vue
Normal file
23
frontend/src/components/locale/DocumentLocaleSetter.vue
Normal 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>
|
||||||
21
frontend/src/components/locale/LocaleSaver.vue
Normal file
21
frontend/src/components/locale/LocaleSaver.vue
Normal 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>
|
||||||
37
frontend/src/components/locale/LocaleSelector.vue
Normal file
37
frontend/src/components/locale/LocaleSelector.vue
Normal 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>
|
||||||
85
frontend/src/components/navbar/NavBar.vue
Normal file
85
frontend/src/components/navbar/NavBar.vue
Normal 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>
|
||||||
|
|
||||||
96
frontend/src/components/sidebar/SideBar.vue
Normal file
96
frontend/src/components/sidebar/SideBar.vue
Normal 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>
|
||||||
26
frontend/src/components/sidebar/SideBarHead.vue
Normal file
26
frontend/src/components/sidebar/SideBarHead.vue
Normal 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>
|
||||||
45
frontend/src/components/sidebar/SideBarNavItem.vue
Normal file
45
frontend/src/components/sidebar/SideBarNavItem.vue
Normal 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>
|
||||||
35
frontend/src/components/sidebar/SideBarNavLink.vue
Normal file
35
frontend/src/components/sidebar/SideBarNavLink.vue
Normal 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>
|
||||||
45
frontend/src/data/api/AiInstanceApi.ts
Normal file
45
frontend/src/data/api/AiInstanceApi.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
frontend/src/data/api/ApiUtils.ts
Normal file
22
frontend/src/data/api/ApiUtils.ts
Normal 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());
|
||||||
|
});
|
||||||
|
}
|
||||||
3
frontend/src/data/models/AiConfiguration.ts
Normal file
3
frontend/src/data/models/AiConfiguration.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export type AiConfiguration = {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
11
frontend/src/data/models/AiInstance.ts
Normal file
11
frontend/src/data/models/AiInstance.ts
Normal 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[];
|
||||||
|
}
|
||||||
1
frontend/src/data/models/chat/ChatMessage.ts
Normal file
1
frontend/src/data/models/chat/ChatMessage.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export type ChatMessage = { 'role': string, 'name': string, 'content': string }
|
||||||
35
frontend/src/locale/de.ts
Normal file
35
frontend/src/locale/de.ts
Normal 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
36
frontend/src/locale/en.ts
Normal 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',
|
||||||
|
},
|
||||||
|
};
|
||||||
32
frontend/src/locale/locale.ts
Normal file
32
frontend/src/locale/locale.ts
Normal 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
24
frontend/src/main.ts
Normal 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');
|
||||||
23
frontend/src/router/index.ts
Normal file
23
frontend/src/router/index.ts
Normal 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;
|
||||||
10
frontend/src/views/AboutView.vue
Normal file
10
frontend/src/views/AboutView.vue
Normal 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>
|
||||||
15
frontend/src/views/HomeView.vue
Normal file
15
frontend/src/views/HomeView.vue
Normal 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>
|
||||||
18
frontend/tsconfig.app.json
Normal file
18
frontend/tsconfig.app.json
Normal 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
14
frontend/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.vitest.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
17
frontend/tsconfig.node.json
Normal file
17
frontend/tsconfig.node.json
Normal 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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
7
frontend/tsconfig.vitest.json
Normal file
7
frontend/tsconfig.vitest.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.app.json",
|
||||||
|
"exclude": [],
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["node", "jsdom"]
|
||||||
|
}
|
||||||
|
}
|
||||||
25
frontend/vite.config.ts
Normal file
25
frontend/vite.config.ts
Normal 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
14
frontend/vitest.config.ts
Normal 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)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user