mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 20:29:06 +00:00
Merge branch 'main' into jose/markers
This commit is contained in:
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@@ -1,6 +1,6 @@
|
||||
name: Deploy to Amazon ECS
|
||||
|
||||
on: [deployment, workflow_dispatch]
|
||||
on: [workflow_dispatch]
|
||||
|
||||
env:
|
||||
# 384658522150.dkr.ecr.us-east-1.amazonaws.com/reflector
|
||||
|
||||
@@ -29,3 +29,11 @@ repos:
|
||||
hooks:
|
||||
- id: black
|
||||
files: ^server/(reflector|tests)/
|
||||
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: 5.12.0
|
||||
hooks:
|
||||
- id: isort
|
||||
name: isort (python)
|
||||
files: ^server/(gpu|evaluate|reflector)/
|
||||
args: ["--profile", "black", "--filter-files"]
|
||||
|
||||
1
server/.gitignore
vendored
1
server/.gitignore
vendored
@@ -178,3 +178,4 @@ audio_*.wav
|
||||
|
||||
# ignore local database
|
||||
reflector.sqlite3
|
||||
data/
|
||||
|
||||
@@ -11,6 +11,29 @@
|
||||
#DATABASE_URL=postgresql://reflector:reflector@localhost:5432/reflector
|
||||
|
||||
|
||||
## =======================================================
|
||||
## User authentication
|
||||
## =======================================================
|
||||
|
||||
## No authentication
|
||||
#AUTH_BACKEND=none
|
||||
|
||||
## Using fief (fief.dev)
|
||||
#AUTH_BACKEND=fief
|
||||
#AUTH_FIEF_URL=https://your-fief-instance....
|
||||
#AUTH_FIEF_CLIENT_ID=xxx
|
||||
#AUTH_FIEF_CLIENT_SECRET=xxx
|
||||
|
||||
|
||||
## =======================================================
|
||||
## Public mode
|
||||
## =======================================================
|
||||
## If set to true, anonymous transcripts will be
|
||||
## accessible to anybody.
|
||||
|
||||
#PUBLIC_MODE=false
|
||||
|
||||
|
||||
## =======================================================
|
||||
## Transcription backend
|
||||
##
|
||||
@@ -45,8 +68,8 @@
|
||||
## llm backend implementation
|
||||
## =======================================================
|
||||
|
||||
## Use oobagooda (default)
|
||||
#LLM_BACKEND=oobagooda
|
||||
## Use oobabooga (default)
|
||||
#LLM_BACKEND=oobabooga
|
||||
#LLM_URL=http://xxx:7860/api/generate/v1
|
||||
|
||||
## Using serverless modal.com (require reflector-gpu-modal deployed)
|
||||
|
||||
@@ -3,14 +3,15 @@ Reflector GPU backend - LLM
|
||||
===========================
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from modal import Image, method, Stub, asgi_app, Secret
|
||||
from typing import Optional
|
||||
|
||||
from modal import Image, Secret, Stub, asgi_app, method
|
||||
|
||||
# LLM
|
||||
LLM_MODEL: str = "lmsys/vicuna-13b-v1.5"
|
||||
LLM_LOW_CPU_MEM_USAGE: bool = False
|
||||
LLM_LOW_CPU_MEM_USAGE: bool = True
|
||||
LLM_TORCH_DTYPE: str = "bfloat16"
|
||||
LLM_MAX_NEW_TOKENS: int = 300
|
||||
|
||||
@@ -49,6 +50,8 @@ llm_image = (
|
||||
"torch",
|
||||
"sentencepiece",
|
||||
"protobuf",
|
||||
"jsonformer==0.12.0",
|
||||
"accelerate==0.21.0",
|
||||
"einops==0.6.1",
|
||||
"hf-transfer~=0.1",
|
||||
"huggingface_hub==0.16.4",
|
||||
@@ -81,6 +84,7 @@ class LLM:
|
||||
|
||||
# generation configuration
|
||||
print("Instance llm generation config")
|
||||
# JSONFormer doesn't yet support generation configs, but keeping for future usage
|
||||
model.config.max_new_tokens = LLM_MAX_NEW_TOKENS
|
||||
gen_cfg = GenerationConfig.from_model_config(model.config)
|
||||
gen_cfg.max_new_tokens = LLM_MAX_NEW_TOKENS
|
||||
@@ -107,16 +111,30 @@ class LLM:
|
||||
return {"status": "ok"}
|
||||
|
||||
@method()
|
||||
def generate(self, prompt: str):
|
||||
def generate(self, prompt: str, schema: str = None):
|
||||
print(f"Generate {prompt=}")
|
||||
# tokenize prompt
|
||||
input_ids = self.tokenizer.encode(prompt, return_tensors="pt").to(
|
||||
self.model.device
|
||||
)
|
||||
output = self.model.generate(input_ids, generation_config=self.gen_cfg)
|
||||
# If a schema is given, conform to schema
|
||||
if schema:
|
||||
print(f"Schema {schema=}")
|
||||
import jsonformer
|
||||
|
||||
# decode output
|
||||
response = self.tokenizer.decode(output[0].cpu(), skip_special_tokens=True)
|
||||
jsonformer_llm = jsonformer.Jsonformer(model=self.model,
|
||||
tokenizer=self.tokenizer,
|
||||
json_schema=json.loads(schema),
|
||||
prompt=prompt,
|
||||
max_string_token_length=self.gen_cfg.max_new_tokens)
|
||||
response = jsonformer_llm()
|
||||
else:
|
||||
# If no schema, perform prompt only generation
|
||||
|
||||
# tokenize prompt
|
||||
input_ids = self.tokenizer.encode(prompt, return_tensors="pt").to(
|
||||
self.model.device
|
||||
)
|
||||
output = self.model.generate(input_ids, generation_config=self.gen_cfg)
|
||||
|
||||
# decode output
|
||||
response = self.tokenizer.decode(output[0].cpu(), skip_special_tokens=True)
|
||||
print(f"Generated {response=}")
|
||||
return {"text": response}
|
||||
|
||||
@@ -135,7 +153,7 @@ class LLM:
|
||||
)
|
||||
@asgi_app()
|
||||
def web():
|
||||
from fastapi import FastAPI, HTTPException, status, Depends
|
||||
from fastapi import Depends, FastAPI, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -154,12 +172,16 @@ def web():
|
||||
|
||||
class LLMRequest(BaseModel):
|
||||
prompt: str
|
||||
schema: Optional[dict] = None
|
||||
|
||||
@app.post("/llm", dependencies=[Depends(apikey_auth)])
|
||||
async def llm(
|
||||
req: LLMRequest,
|
||||
):
|
||||
func = llmstub.generate.spawn(prompt=req.prompt)
|
||||
if req.schema:
|
||||
func = llmstub.generate.spawn(prompt=req.prompt, schema=json.dumps(req.schema))
|
||||
else:
|
||||
func = llmstub.generate.spawn(prompt=req.prompt)
|
||||
result = func.get()
|
||||
return result
|
||||
|
||||
|
||||
66
server/poetry.lock
generated
66
server/poetry.lock
generated
@@ -930,6 +930,23 @@ mysql = ["aiomysql"]
|
||||
postgresql = ["asyncpg"]
|
||||
sqlite = ["aiosqlite"]
|
||||
|
||||
[[package]]
|
||||
name = "deprecated"
|
||||
version = "1.2.14"
|
||||
description = "Python @deprecated decorator to deprecate old python classes, functions or methods."
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
files = [
|
||||
{file = "Deprecated-1.2.14-py2.py3-none-any.whl", hash = "sha256:6fac8b097794a90302bdbb17b9b815e732d3c4720583ff1b198499d78470466c"},
|
||||
{file = "Deprecated-1.2.14.tar.gz", hash = "sha256:e5323eb936458dccc2582dc6f9c322c852a775a27065ff2b0c4970b9d53d01b3"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
wrapt = ">=1.10,<2"
|
||||
|
||||
[package.extras]
|
||||
dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "sphinx (<2)", "tox"]
|
||||
|
||||
[[package]]
|
||||
name = "dnspython"
|
||||
version = "2.4.1"
|
||||
@@ -1022,6 +1039,28 @@ tokenizers = "==0.13.*"
|
||||
conversion = ["transformers[torch] (>=4.23)"]
|
||||
dev = ["black (==23.*)", "flake8 (==6.*)", "isort (==5.*)", "pytest (==7.*)"]
|
||||
|
||||
[[package]]
|
||||
name = "fief-client"
|
||||
version = "0.17.0"
|
||||
description = "Fief Client for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "fief_client-0.17.0-py3-none-any.whl", hash = "sha256:ecc8674ecaf58fc7d2926f5a0f49fabd3a1a03e278f030977a97ecb716b8884d"},
|
||||
{file = "fief_client-0.17.0.tar.gz", hash = "sha256:f1f9a10c760c29811a8cce2c1d58938090901772826dda973b67dde1bce3bafd"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
fastapi = {version = "*", optional = true, markers = "extra == \"fastapi\""}
|
||||
httpx = ">=0.21.3,<0.25.0"
|
||||
jwcrypto = ">=1.4,<2.0.0"
|
||||
makefun = {version = ">=1.14.0,<2.0.0", optional = true, markers = "extra == \"fastapi\""}
|
||||
|
||||
[package.extras]
|
||||
cli = ["halo"]
|
||||
fastapi = ["fastapi", "makefun (>=1.14.0,<2.0.0)"]
|
||||
flask = ["flask"]
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "3.12.2"
|
||||
@@ -1530,6 +1569,20 @@ files = [
|
||||
{file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jwcrypto"
|
||||
version = "1.5.0"
|
||||
description = "Implementation of JOSE Web standards"
|
||||
optional = false
|
||||
python-versions = ">= 3.6"
|
||||
files = [
|
||||
{file = "jwcrypto-1.5.0.tar.gz", hash = "sha256:2c1dc51cf8e38ddf324795dfe9426dee9dd46caf47f535ccbc18781fba810b8d"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
cryptography = ">=3.4"
|
||||
deprecated = "*"
|
||||
|
||||
[[package]]
|
||||
name = "levenshtein"
|
||||
version = "0.21.1"
|
||||
@@ -1662,6 +1715,17 @@ win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""}
|
||||
[package.extras]
|
||||
dev = ["Sphinx (==5.3.0)", "colorama (==0.4.5)", "colorama (==0.4.6)", "freezegun (==1.1.0)", "freezegun (==1.2.2)", "mypy (==v0.910)", "mypy (==v0.971)", "mypy (==v0.990)", "pre-commit (==3.2.1)", "pytest (==6.1.2)", "pytest (==7.2.1)", "pytest-cov (==2.12.1)", "pytest-cov (==4.0.0)", "pytest-mypy-plugins (==1.10.1)", "pytest-mypy-plugins (==1.9.3)", "sphinx-autobuild (==2021.3.14)", "sphinx-rtd-theme (==1.2.0)", "tox (==3.27.1)", "tox (==4.4.6)"]
|
||||
|
||||
[[package]]
|
||||
name = "makefun"
|
||||
version = "1.15.1"
|
||||
description = "Small library to dynamically create python functions."
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
{file = "makefun-1.15.1-py2.py3-none-any.whl", hash = "sha256:a63cfc7b47a539c76d97bd4fdb833c7d0461e759fd1225f580cb4be6200294d4"},
|
||||
{file = "makefun-1.15.1.tar.gz", hash = "sha256:40b0f118b6ded0d8d78c78f1eb679b8b6b2462e3c1b3e05fb1b2da8cd46b48a5"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mpmath"
|
||||
version = "1.3.0"
|
||||
@@ -3234,4 +3298,4 @@ multidict = ">=4.0"
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.11"
|
||||
content-hash = "ea523f9b74581a7867097a6249d416d8836f4daaf33fde65ea343e4d3502c71c"
|
||||
content-hash = "d84edfea8ac7a849340af8eb5db47df9c13a7cc1c640062ebedb2a808be0de4e"
|
||||
|
||||
@@ -25,6 +25,7 @@ httpx = "^0.24.1"
|
||||
fastapi-pagination = "^0.12.6"
|
||||
databases = {extras = ["aiosqlite", "asyncpg"], version = "^0.7.0"}
|
||||
sqlalchemy = "<1.5"
|
||||
fief-client = {extras = ["fastapi"], version = "^0.17.0"}
|
||||
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import reflector.auth # noqa
|
||||
import reflector.db # noqa
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi_pagination import add_pagination
|
||||
from fastapi.routing import APIRoute
|
||||
import reflector.db # noqa
|
||||
from reflector.views.rtc_offer import router as rtc_offer_router
|
||||
from reflector.views.transcripts import router as transcripts_router
|
||||
from reflector.events import subscribers_startup, subscribers_shutdown
|
||||
from fastapi_pagination import add_pagination
|
||||
from reflector.events import subscribers_shutdown, subscribers_startup
|
||||
from reflector.logger import logger
|
||||
from reflector.settings import settings
|
||||
from contextlib import asynccontextmanager
|
||||
from reflector.views.rtc_offer import router as rtc_offer_router
|
||||
from reflector.views.transcripts import router as transcripts_router
|
||||
from reflector.views.user import router as user_router
|
||||
|
||||
try:
|
||||
import sentry_sdk
|
||||
@@ -49,6 +52,7 @@ app.add_middleware(
|
||||
# register views
|
||||
app.include_router(rtc_offer_router)
|
||||
app.include_router(transcripts_router, prefix="/v1")
|
||||
app.include_router(user_router, prefix="/v1")
|
||||
add_pagination(app)
|
||||
|
||||
|
||||
|
||||
13
server/reflector/auth/__init__.py
Normal file
13
server/reflector/auth/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from reflector.settings import settings
|
||||
from reflector.logger import logger
|
||||
import importlib
|
||||
|
||||
logger.info(f"User authentication using {settings.AUTH_BACKEND}")
|
||||
module_name = f"reflector.auth.auth_{settings.AUTH_BACKEND}"
|
||||
auth_module = importlib.import_module(module_name)
|
||||
|
||||
UserInfo = auth_module.UserInfo
|
||||
AccessTokenInfo = auth_module.AccessTokenInfo
|
||||
authenticated = auth_module.authenticated
|
||||
current_user = auth_module.current_user
|
||||
current_user_optional = auth_module.current_user_optional
|
||||
25
server/reflector/auth/auth_fief.py
Normal file
25
server/reflector/auth/auth_fief.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from fastapi.security import OAuth2AuthorizationCodeBearer
|
||||
from fief_client import FiefAccessTokenInfo, FiefAsync, FiefUserInfo
|
||||
from fief_client.integrations.fastapi import FiefAuth
|
||||
from reflector.settings import settings
|
||||
|
||||
fief = FiefAsync(
|
||||
settings.AUTH_FIEF_URL,
|
||||
settings.AUTH_FIEF_CLIENT_ID,
|
||||
settings.AUTH_FIEF_CLIENT_SECRET,
|
||||
)
|
||||
|
||||
scheme = OAuth2AuthorizationCodeBearer(
|
||||
f"{settings.AUTH_FIEF_URL}/authorize",
|
||||
f"{settings.AUTH_FIEF_URL}/api/token",
|
||||
scopes={"openid": "openid", "offline_access": "offline_access"},
|
||||
auto_error=False,
|
||||
)
|
||||
|
||||
auth = FiefAuth(fief, scheme)
|
||||
|
||||
UserInfo = FiefUserInfo
|
||||
AccessTokenInfo = FiefAccessTokenInfo
|
||||
authenticated = auth.authenticated()
|
||||
current_user = auth.current_user()
|
||||
current_user_optional = auth.current_user(optional=True)
|
||||
26
server/reflector/auth/auth_none.py
Normal file
26
server/reflector/auth/auth_none.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Annotated
|
||||
from fastapi import Depends
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False)
|
||||
|
||||
|
||||
class UserInfo(BaseModel):
|
||||
sub: str
|
||||
|
||||
|
||||
class AccessTokenInfo(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
def authenticated(token: Annotated[str, Depends(oauth2_scheme)]):
|
||||
return None
|
||||
|
||||
|
||||
def current_user(token: Annotated[str, Depends(oauth2_scheme)]):
|
||||
return None
|
||||
|
||||
|
||||
def current_user_optional(token: Annotated[str, Depends(oauth2_scheme)]):
|
||||
return None
|
||||
@@ -1,10 +1,11 @@
|
||||
from reflector.settings import settings
|
||||
from reflector.utils.retry import retry
|
||||
from reflector.logger import logger as reflector_logger
|
||||
from time import monotonic
|
||||
import importlib
|
||||
import json
|
||||
import re
|
||||
from time import monotonic
|
||||
|
||||
from reflector.logger import logger as reflector_logger
|
||||
from reflector.settings import settings
|
||||
from reflector.utils.retry import retry
|
||||
|
||||
|
||||
class LLM:
|
||||
@@ -20,7 +21,7 @@ class LLM:
|
||||
Return an instance depending on the settings.
|
||||
Settings used:
|
||||
|
||||
- `LLM_BACKEND`: key of the backend, defaults to `oobagooda`
|
||||
- `LLM_BACKEND`: key of the backend, defaults to `oobabooga`
|
||||
- `LLM_URL`: url of the backend
|
||||
"""
|
||||
if name is None:
|
||||
@@ -44,10 +45,16 @@ class LLM:
|
||||
async def _warmup(self, logger: reflector_logger):
|
||||
pass
|
||||
|
||||
async def generate(self, prompt: str, logger: reflector_logger, **kwargs) -> dict:
|
||||
async def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
logger: reflector_logger,
|
||||
schema: dict | None = None,
|
||||
**kwargs,
|
||||
) -> dict:
|
||||
logger.info("LLM generate", prompt=repr(prompt))
|
||||
try:
|
||||
result = await retry(self._generate)(prompt=prompt, **kwargs)
|
||||
result = await retry(self._generate)(prompt=prompt, schema=schema, **kwargs)
|
||||
except Exception:
|
||||
logger.exception("Failed to call llm after retrying")
|
||||
raise
|
||||
@@ -59,7 +66,7 @@ class LLM:
|
||||
|
||||
return result
|
||||
|
||||
async def _generate(self, prompt: str, **kwargs) -> str:
|
||||
async def _generate(self, prompt: str, schema: dict | None, **kwargs) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def _parse_json(self, result: str) -> dict:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import httpx
|
||||
from reflector.llm.base import LLM
|
||||
from reflector.settings import settings
|
||||
from reflector.utils.retry import retry
|
||||
import httpx
|
||||
|
||||
|
||||
class BananaLLM(LLM):
|
||||
@@ -13,18 +13,22 @@ class BananaLLM(LLM):
|
||||
"X-Banana-Model-Key": settings.LLM_BANANA_MODEL_KEY,
|
||||
}
|
||||
|
||||
async def _generate(self, prompt: str, **kwargs):
|
||||
async def _generate(self, prompt: str, schema: dict | None, **kwargs):
|
||||
json_payload = {"prompt": prompt}
|
||||
if schema:
|
||||
json_payload["schema"] = schema
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await retry(client.post)(
|
||||
settings.LLM_URL,
|
||||
headers=self.headers,
|
||||
json={"prompt": prompt},
|
||||
json=json_payload,
|
||||
timeout=self.timeout,
|
||||
retry_timeout=300, # as per their sdk
|
||||
)
|
||||
response.raise_for_status()
|
||||
text = response.json()["text"]
|
||||
text = text[len(prompt) :] # remove prompt
|
||||
if not schema:
|
||||
text = text[len(prompt) :]
|
||||
return text
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import httpx
|
||||
from reflector.llm.base import LLM
|
||||
from reflector.settings import settings
|
||||
from reflector.utils.retry import retry
|
||||
import httpx
|
||||
|
||||
|
||||
class ModalLLM(LLM):
|
||||
@@ -23,18 +23,22 @@ class ModalLLM(LLM):
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
async def _generate(self, prompt: str, **kwargs):
|
||||
async def _generate(self, prompt: str, schema: dict | None, **kwargs):
|
||||
json_payload = {"prompt": prompt}
|
||||
if schema:
|
||||
json_payload["schema"] = schema
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await retry(client.post)(
|
||||
self.llm_url,
|
||||
headers=self.headers,
|
||||
json={"prompt": prompt},
|
||||
json=json_payload,
|
||||
timeout=self.timeout,
|
||||
retry_timeout=60 * 5,
|
||||
)
|
||||
response.raise_for_status()
|
||||
text = response.json()["text"]
|
||||
text = text[len(prompt) :] # remove prompt
|
||||
if not schema:
|
||||
text = text[len(prompt) :]
|
||||
return text
|
||||
|
||||
|
||||
@@ -48,6 +52,14 @@ if __name__ == "__main__":
|
||||
result = await llm.generate("Hello, my name is", logger=logger)
|
||||
print(result)
|
||||
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}},
|
||||
}
|
||||
|
||||
result = await llm.generate("Hello, my name is", schema=schema, logger=logger)
|
||||
print(result)
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import httpx
|
||||
from reflector.llm.base import LLM
|
||||
from reflector.settings import settings
|
||||
import httpx
|
||||
|
||||
|
||||
class OobagoodaLLM(LLM):
|
||||
async def _generate(self, prompt: str, **kwargs):
|
||||
class OobaboogaLLM(LLM):
|
||||
async def _generate(self, prompt: str, schema: dict | None, **kwargs):
|
||||
json_payload = {"prompt": prompt}
|
||||
if schema:
|
||||
json_payload["schema"] = schema
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
settings.LLM_URL,
|
||||
headers={"Content-Type": "application/json"},
|
||||
json={"prompt": prompt},
|
||||
json=json_payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
LLM.register("oobagooda", OobagoodaLLM)
|
||||
LLM.register("oobabooga", OobaboogaLLM)
|
||||
@@ -1,7 +1,7 @@
|
||||
import httpx
|
||||
from reflector.llm.base import LLM
|
||||
from reflector.logger import logger
|
||||
from reflector.settings import settings
|
||||
import httpx
|
||||
|
||||
|
||||
class OpenAILLM(LLM):
|
||||
@@ -15,7 +15,7 @@ class OpenAILLM(LLM):
|
||||
self.max_tokens = settings.LLM_MAX_TOKENS
|
||||
logger.info(f"LLM use openai backend at {self.openai_url}")
|
||||
|
||||
async def _generate(self, prompt: str, **kwargs) -> str:
|
||||
async def _generate(self, prompt: str, schema: dict | None, **kwargs) -> str:
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.openai_key}",
|
||||
|
||||
@@ -26,13 +26,13 @@ class AudioFileWriterProcessor(Processor):
|
||||
self.out_stream = self.out_container.add_stream(
|
||||
"pcm_s16le", rate=data.sample_rate
|
||||
)
|
||||
for packet in self.out_stream.encode(data):
|
||||
self.out_container.mux(packet)
|
||||
for packet in self.out_stream.encode(data):
|
||||
self.out_container.mux(packet)
|
||||
await self.emit(data)
|
||||
|
||||
async def _flush(self):
|
||||
if self.out_container:
|
||||
for packet in self.out_stream.encode(None):
|
||||
for packet in self.out_stream.encode():
|
||||
self.out_container.mux(packet)
|
||||
self.out_container.close()
|
||||
self.out_container = None
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from reflector.processors.base import Processor
|
||||
from reflector.processors.types import Transcript, TitleSummary
|
||||
from reflector.utils.retry import retry
|
||||
from reflector.llm import LLM
|
||||
from reflector.processors.base import Processor
|
||||
from reflector.processors.types import TitleSummary, Transcript
|
||||
from reflector.utils.retry import retry
|
||||
|
||||
|
||||
class TranscriptTopicDetectorProcessor(Processor):
|
||||
@@ -15,9 +15,11 @@ class TranscriptTopicDetectorProcessor(Processor):
|
||||
PROMPT = """
|
||||
### Human:
|
||||
Create a JSON object as response.The JSON object must have 2 fields:
|
||||
i) title and ii) summary.For the title field,generate a short title
|
||||
for the given text. For the summary field, summarize the given text
|
||||
in three sentences.
|
||||
i) title and ii) summary.
|
||||
|
||||
For the title field, generate a short title for the given text.
|
||||
For the summary field, summarize the given text in a maximum of
|
||||
three sentences.
|
||||
|
||||
{input_text}
|
||||
|
||||
@@ -30,6 +32,13 @@ class TranscriptTopicDetectorProcessor(Processor):
|
||||
self.transcript = None
|
||||
self.min_transcript_length = min_transcript_length
|
||||
self.llm = LLM.get_instance()
|
||||
self.topic_detector_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"summary": {"type": "string"},
|
||||
},
|
||||
}
|
||||
|
||||
async def _warmup(self):
|
||||
await self.llm.warmup(logger=self.logger)
|
||||
@@ -52,7 +61,9 @@ class TranscriptTopicDetectorProcessor(Processor):
|
||||
text = self.transcript.text
|
||||
self.logger.info(f"Topic detector got {len(text)} length transcript")
|
||||
prompt = self.PROMPT.format(input_text=text)
|
||||
result = await retry(self.llm.generate)(prompt=prompt, logger=self.logger)
|
||||
result = await retry(self.llm.generate)(
|
||||
prompt=prompt, schema=self.topic_detector_schema, logger=self.logger
|
||||
)
|
||||
summary = TitleSummary(
|
||||
title=result["title"],
|
||||
summary=result["summary"],
|
||||
|
||||
@@ -55,8 +55,8 @@ class Settings(BaseSettings):
|
||||
TRANSCRIPT_STORAGE_AWS_SECRET_ACCESS_KEY: str | None = None
|
||||
|
||||
# LLM
|
||||
# available backend: openai, banana, modal, oobagooda
|
||||
LLM_BACKEND: str = "oobagooda"
|
||||
# available backend: openai, banana, modal, oobabooga
|
||||
LLM_BACKEND: str = "oobabooga"
|
||||
|
||||
# LLM common configuration
|
||||
LLM_URL: str | None = None
|
||||
@@ -79,5 +79,17 @@ class Settings(BaseSettings):
|
||||
# Sentry
|
||||
SENTRY_DSN: str | None = None
|
||||
|
||||
# User authentication (none, fief)
|
||||
AUTH_BACKEND: str = "none"
|
||||
|
||||
# User authentication using fief
|
||||
AUTH_FIEF_URL: str | None = None
|
||||
AUTH_FIEF_CLIENT_ID: str | None = None
|
||||
AUTH_FIEF_CLIENT_SECRET: str | None = None
|
||||
|
||||
# API public mode
|
||||
# if set, all anonymous record will be public
|
||||
PUBLIC_MODE: bool = False
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from tempfile import NamedTemporaryFile
|
||||
from typing import Annotated, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
import av
|
||||
import reflector.auth as auth
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
Request,
|
||||
WebSocket,
|
||||
WebSocketDisconnect,
|
||||
)
|
||||
from fastapi.responses import FileResponse
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from pydantic import BaseModel, Field
|
||||
from uuid import uuid4
|
||||
from datetime import datetime
|
||||
from fastapi_pagination import Page, paginate
|
||||
from reflector.logger import logger
|
||||
from pydantic import BaseModel, Field
|
||||
from reflector.db import database, transcripts
|
||||
from reflector.logger import logger
|
||||
from reflector.settings import settings
|
||||
from .rtc_offer import rtc_offer_base, RtcOffer, PipelineEvent
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
from tempfile import NamedTemporaryFile
|
||||
import av
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from .rtc_offer import PipelineEvent, RtcOffer, rtc_offer_base
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -60,6 +63,7 @@ class TranscriptEvent(BaseModel):
|
||||
|
||||
class Transcript(BaseModel):
|
||||
id: str = Field(default_factory=generate_uuid4)
|
||||
user_id: str | None = None
|
||||
name: str = Field(default_factory=generate_transcript_name)
|
||||
status: str = "idle"
|
||||
locked: bool = False
|
||||
@@ -108,7 +112,9 @@ class Transcript(BaseModel):
|
||||
out.close()
|
||||
|
||||
# move temporary file to final location
|
||||
Path(tmp.name).rename(fn)
|
||||
import shutil
|
||||
|
||||
shutil.move(tmp.name, fn.as_posix())
|
||||
|
||||
def unlink(self):
|
||||
self.data_path.unlink(missing_ok=True)
|
||||
@@ -127,20 +133,22 @@ class Transcript(BaseModel):
|
||||
|
||||
|
||||
class TranscriptController:
|
||||
async def get_all(self) -> list[Transcript]:
|
||||
query = transcripts.select()
|
||||
async def get_all(self, user_id: str | None = None) -> list[Transcript]:
|
||||
query = transcripts.select().where(transcripts.c.user_id == user_id)
|
||||
results = await database.fetch_all(query)
|
||||
return results
|
||||
|
||||
async def get_by_id(self, transcript_id: str) -> Transcript | None:
|
||||
async def get_by_id(self, transcript_id: str, **kwargs) -> Transcript | None:
|
||||
query = transcripts.select().where(transcripts.c.id == transcript_id)
|
||||
if "user_id" in kwargs:
|
||||
query = query.where(transcripts.c.user_id == kwargs["user_id"])
|
||||
result = await database.fetch_one(query)
|
||||
if not result:
|
||||
return None
|
||||
return Transcript(**result)
|
||||
|
||||
async def add(self, name: str):
|
||||
transcript = Transcript(name=name)
|
||||
async def add(self, name: str, user_id: str | None = None):
|
||||
transcript = Transcript(name=name, user_id=user_id)
|
||||
query = transcripts.insert().values(**transcript.model_dump())
|
||||
await database.execute(query)
|
||||
return transcript
|
||||
@@ -155,10 +163,14 @@ class TranscriptController:
|
||||
for key, value in values.items():
|
||||
setattr(transcript, key, value)
|
||||
|
||||
async def remove_by_id(self, transcript_id: str) -> None:
|
||||
transcript = await self.get_by_id(transcript_id)
|
||||
async def remove_by_id(
|
||||
self, transcript_id: str, user_id: str | None = None
|
||||
) -> None:
|
||||
transcript = await self.get_by_id(transcript_id, user_id=user_id)
|
||||
if not transcript:
|
||||
return
|
||||
if user_id is not None and transcript.user_id != user_id:
|
||||
return
|
||||
transcript.unlink()
|
||||
query = transcripts.delete().where(transcripts.c.id == transcript_id)
|
||||
await database.execute(query)
|
||||
@@ -199,13 +211,23 @@ class DeletionStatus(BaseModel):
|
||||
|
||||
|
||||
@router.get("/transcripts", response_model=Page[GetTranscript])
|
||||
async def transcripts_list():
|
||||
return paginate(await transcripts_controller.get_all())
|
||||
async def transcripts_list(
|
||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
):
|
||||
if not user and not settings.PUBLIC_MODE:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
user_id = user["sub"] if user else None
|
||||
return paginate(await transcripts_controller.get_all(user_id=user_id))
|
||||
|
||||
|
||||
@router.post("/transcripts", response_model=GetTranscript)
|
||||
async def transcripts_create(info: CreateTranscript):
|
||||
return await transcripts_controller.add(info.name)
|
||||
async def transcripts_create(
|
||||
info: CreateTranscript,
|
||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
):
|
||||
user_id = user["sub"] if user else None
|
||||
return await transcripts_controller.add(info.name, user_id=user_id)
|
||||
|
||||
|
||||
# ==============================================================
|
||||
@@ -214,16 +236,25 @@ async def transcripts_create(info: CreateTranscript):
|
||||
|
||||
|
||||
@router.get("/transcripts/{transcript_id}", response_model=GetTranscript)
|
||||
async def transcript_get(transcript_id: str):
|
||||
transcript = await transcripts_controller.get_by_id(transcript_id)
|
||||
async def transcript_get(
|
||||
transcript_id: str,
|
||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
):
|
||||
user_id = user["sub"] if user else None
|
||||
transcript = await transcripts_controller.get_by_id(transcript_id, user_id=user_id)
|
||||
if not transcript:
|
||||
raise HTTPException(status_code=404, detail="Transcript not found")
|
||||
return transcript
|
||||
|
||||
|
||||
@router.patch("/transcripts/{transcript_id}", response_model=GetTranscript)
|
||||
async def transcript_update(transcript_id: str, info: UpdateTranscript):
|
||||
transcript = await transcripts_controller.get_by_id(transcript_id)
|
||||
async def transcript_update(
|
||||
transcript_id: str,
|
||||
info: UpdateTranscript,
|
||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
):
|
||||
user_id = user["sub"] if user else None
|
||||
transcript = await transcripts_controller.get_by_id(transcript_id, user_id=user_id)
|
||||
if not transcript:
|
||||
raise HTTPException(status_code=404, detail="Transcript not found")
|
||||
values = {}
|
||||
@@ -236,17 +267,25 @@ async def transcript_update(transcript_id: str, info: UpdateTranscript):
|
||||
|
||||
|
||||
@router.delete("/transcripts/{transcript_id}", response_model=DeletionStatus)
|
||||
async def transcript_delete(transcript_id: str):
|
||||
transcript = await transcripts_controller.get_by_id(transcript_id)
|
||||
async def transcript_delete(
|
||||
transcript_id: str,
|
||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
):
|
||||
user_id = user["sub"] if user else None
|
||||
transcript = await transcripts_controller.get_by_id(transcript_id, user_id=user_id)
|
||||
if not transcript:
|
||||
raise HTTPException(status_code=404, detail="Transcript not found")
|
||||
await transcripts_controller.remove_by_id(transcript.id)
|
||||
await transcripts_controller.remove_by_id(transcript.id, user_id=user_id)
|
||||
return DeletionStatus(status="ok")
|
||||
|
||||
|
||||
@router.get("/transcripts/{transcript_id}/audio")
|
||||
async def transcript_get_audio(transcript_id: str):
|
||||
transcript = await transcripts_controller.get_by_id(transcript_id)
|
||||
async def transcript_get_audio(
|
||||
transcript_id: str,
|
||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
):
|
||||
user_id = user["sub"] if user else None
|
||||
transcript = await transcripts_controller.get_by_id(transcript_id, user_id=user_id)
|
||||
if not transcript:
|
||||
raise HTTPException(status_code=404, detail="Transcript not found")
|
||||
|
||||
@@ -257,8 +296,12 @@ async def transcript_get_audio(transcript_id: str):
|
||||
|
||||
|
||||
@router.get("/transcripts/{transcript_id}/audio/mp3")
|
||||
async def transcript_get_audio_mp3(transcript_id: str):
|
||||
transcript = await transcripts_controller.get_by_id(transcript_id)
|
||||
async def transcript_get_audio_mp3(
|
||||
transcript_id: str,
|
||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
):
|
||||
user_id = user["sub"] if user else None
|
||||
transcript = await transcripts_controller.get_by_id(transcript_id, user_id=user_id)
|
||||
if not transcript:
|
||||
raise HTTPException(status_code=404, detail="Transcript not found")
|
||||
|
||||
@@ -271,8 +314,12 @@ async def transcript_get_audio_mp3(transcript_id: str):
|
||||
|
||||
|
||||
@router.get("/transcripts/{transcript_id}/topics", response_model=list[TranscriptTopic])
|
||||
async def transcript_get_topics(transcript_id: str):
|
||||
transcript = await transcripts_controller.get_by_id(transcript_id)
|
||||
async def transcript_get_topics(
|
||||
transcript_id: str,
|
||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
):
|
||||
user_id = user["sub"] if user else None
|
||||
transcript = await transcripts_controller.get_by_id(transcript_id, user_id=user_id)
|
||||
if not transcript:
|
||||
raise HTTPException(status_code=404, detail="Transcript not found")
|
||||
return transcript.topics
|
||||
@@ -319,7 +366,12 @@ ws_manager = WebsocketManager()
|
||||
|
||||
|
||||
@router.websocket("/transcripts/{transcript_id}/events")
|
||||
async def transcript_events_websocket(transcript_id: str, websocket: WebSocket):
|
||||
async def transcript_events_websocket(
|
||||
transcript_id: str,
|
||||
websocket: WebSocket,
|
||||
# user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
):
|
||||
# user_id = user["sub"] if user else None
|
||||
transcript = await transcripts_controller.get_by_id(transcript_id)
|
||||
if not transcript:
|
||||
raise HTTPException(status_code=404, detail="Transcript not found")
|
||||
@@ -420,9 +472,13 @@ async def handle_rtc_event(event: PipelineEvent, args, data):
|
||||
|
||||
@router.post("/transcripts/{transcript_id}/record/webrtc")
|
||||
async def transcript_record_webrtc(
|
||||
transcript_id: str, params: RtcOffer, request: Request
|
||||
transcript_id: str,
|
||||
params: RtcOffer,
|
||||
request: Request,
|
||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
):
|
||||
transcript = await transcripts_controller.get_by_id(transcript_id)
|
||||
user_id = user["sub"] if user else None
|
||||
transcript = await transcripts_controller.get_by_id(transcript_id, user_id=user_id)
|
||||
if not transcript:
|
||||
raise HTTPException(status_code=404, detail="Transcript not found")
|
||||
|
||||
|
||||
20
server/reflector/views/user.py
Normal file
20
server/reflector/views/user.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from typing import Annotated, Optional
|
||||
|
||||
import reflector.auth as auth
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class UserInfo(BaseModel):
|
||||
sub: str
|
||||
email: Optional[str]
|
||||
email_verified: Optional[bool]
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def user_me(
|
||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
) -> UserInfo | None:
|
||||
return user
|
||||
BIN
server/test.db
BIN
server/test.db
Binary file not shown.
16
server/tests/conftest.py
Normal file
16
server/tests/conftest.py
Normal file
@@ -0,0 +1,16 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="function", autouse=True)
|
||||
@pytest.mark.asyncio
|
||||
async def setup_database():
|
||||
from reflector.settings import settings
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
with NamedTemporaryFile() as f:
|
||||
settings.DATABASE_URL = f"sqlite:///{f.name}"
|
||||
from reflector.db import engine, metadata
|
||||
|
||||
metadata.create_all(bind=engine)
|
||||
|
||||
yield
|
||||
@@ -15,7 +15,7 @@ async def test_basic_process(event_loop):
|
||||
settings.TRANSCRIPT_BACKEND = "whisper"
|
||||
|
||||
class LLMTest(LLM):
|
||||
async def _generate(self, prompt: str, **kwargs) -> str:
|
||||
async def _generate(self, prompt: str, schema: dict | None, **kwargs) -> str:
|
||||
return {
|
||||
"title": "TITLE",
|
||||
"summary": "SUMMARY",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from reflector.app import app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_create():
|
||||
from reflector.app import app
|
||||
|
||||
async with AsyncClient(app=app, base_url="http://test/v1") as ac:
|
||||
response = await ac.post("/transcripts", json={"name": "test"})
|
||||
assert response.status_code == 200
|
||||
@@ -21,6 +22,8 @@ async def test_transcript_create():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_get_update_name():
|
||||
from reflector.app import app
|
||||
|
||||
async with AsyncClient(app=app, base_url="http://test/v1") as ac:
|
||||
response = await ac.post("/transcripts", json={"name": "test"})
|
||||
assert response.status_code == 200
|
||||
@@ -42,9 +45,45 @@ async def test_transcript_get_update_name():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcripts_list():
|
||||
async def test_transcripts_list_anonymous():
|
||||
# XXX this test is a bit fragile, as it depends on the storage which
|
||||
# is shared between tests
|
||||
from reflector.app import app
|
||||
from reflector.settings import settings
|
||||
|
||||
async with AsyncClient(app=app, base_url="http://test/v1") as ac:
|
||||
response = await ac.get("/transcripts")
|
||||
assert response.status_code == 401
|
||||
|
||||
# if public mode, it should be allowed
|
||||
try:
|
||||
settings.PUBLIC_MODE = True
|
||||
async with AsyncClient(app=app, base_url="http://test/v1") as ac:
|
||||
response = await ac.get("/transcripts")
|
||||
assert response.status_code == 200
|
||||
finally:
|
||||
settings.PUBLIC_MODE = False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@pytest.mark.asyncio
|
||||
async def authenticated_client():
|
||||
from reflector.app import app
|
||||
from reflector.auth import current_user, current_user_optional
|
||||
|
||||
app.dependency_overrides[current_user] = lambda: {"sub": "randomuserid"}
|
||||
app.dependency_overrides[current_user_optional] = lambda: {"sub": "randomuserid"}
|
||||
yield
|
||||
del app.dependency_overrides[current_user]
|
||||
del app.dependency_overrides[current_user_optional]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcripts_list_authenticated(authenticated_client):
|
||||
# XXX this test is a bit fragile, as it depends on the storage which
|
||||
# is shared between tests
|
||||
from reflector.app import app
|
||||
|
||||
async with AsyncClient(app=app, base_url="http://test/v1") as ac:
|
||||
response = await ac.post("/transcripts", json={"name": "testxx1"})
|
||||
assert response.status_code == 200
|
||||
@@ -64,6 +103,8 @@ async def test_transcripts_list():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_delete():
|
||||
from reflector.app import app
|
||||
|
||||
async with AsyncClient(app=app, base_url="http://test/v1") as ac:
|
||||
response = await ac.post("/transcripts", json={"name": "testdel1"})
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -3,17 +3,16 @@
|
||||
# FIXME test websocket connection after RTC is finished still send the full events
|
||||
# FIXME try with locked session, RTC should not work
|
||||
|
||||
import pytest
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
from httpx import AsyncClient
|
||||
|
||||
from reflector.app import app
|
||||
from uvicorn import Config, Server
|
||||
import threading
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from httpx_ws import aconnect_ws
|
||||
from uvicorn import Config, Server
|
||||
|
||||
|
||||
class ThreadedUvicorn:
|
||||
@@ -61,7 +60,7 @@ async def dummy_llm():
|
||||
from reflector.llm.base import LLM
|
||||
|
||||
class TestLLM(LLM):
|
||||
async def _generate(self, prompt: str, **kwargs):
|
||||
async def _generate(self, prompt: str, schema: dict | None, **kwargs):
|
||||
return json.dumps({"title": "LLM TITLE", "summary": "LLM SUMMARY"})
|
||||
|
||||
with patch("reflector.llm.base.LLM.get_instance") as mock_llm:
|
||||
@@ -76,6 +75,7 @@ async def test_transcript_rtc_and_websocket(tmpdir, dummy_transcript, dummy_llm)
|
||||
# to be able to connect with aiortc
|
||||
|
||||
from reflector.settings import settings
|
||||
from reflector.app import app
|
||||
|
||||
settings.DATA_DIR = Path(tmpdir)
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ This data is then returned from the `useWebRTC` hook and can be used in your com
|
||||
To generate the TypeScript files from the openapi.json file, make sure the python server is running, then run:
|
||||
|
||||
```
|
||||
openapi-generator-cli generate -i http://localhost:1250/openapi.json -g typescript-fetch -o app/api
|
||||
yarn openapi
|
||||
```
|
||||
|
||||
You may need to run `yarn global add @openapitools/openapi-generator-cli` first. You also need a Java runtime installed on your machine.
|
||||
|
||||
11
www/app/(auth)/fiefWrapper.tsx
Normal file
11
www/app/(auth)/fiefWrapper.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { FiefAuthProvider } from "@fief/fief/nextjs/react";
|
||||
|
||||
export default function FiefWrapper({ children }) {
|
||||
return (
|
||||
<FiefAuthProvider currentUserPath="/api/current-user">
|
||||
{children}
|
||||
</FiefAuthProvider>
|
||||
);
|
||||
}
|
||||
43
www/app/(auth)/userInfo.tsx
Normal file
43
www/app/(auth)/userInfo.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
import {
|
||||
useFiefIsAuthenticated,
|
||||
useFiefUserinfo,
|
||||
} from "@fief/fief/nextjs/react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
|
||||
export default function UserInfo() {
|
||||
const isAuthenticated = useFiefIsAuthenticated();
|
||||
const userinfo = useFiefUserinfo();
|
||||
|
||||
return (
|
||||
<header className="bg-black w-full border-b border-gray-700 flex justify-between items-center py-2 mb-3">
|
||||
{/* Logo on the left */}
|
||||
<Link href="/">
|
||||
<Image
|
||||
src="/reach.png"
|
||||
width={16}
|
||||
height={16}
|
||||
className="h-6 w-auto ml-2"
|
||||
alt="Reflector"
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{/* Text link on the right */}
|
||||
{!isAuthenticated && (
|
||||
<span className="text-white hover:underline font-thin px-2">
|
||||
<Link href="/login">Log in or create account</Link>
|
||||
</span>
|
||||
)}
|
||||
{isAuthenticated && (
|
||||
<span className="text-white font-thin px-2">
|
||||
{userinfo?.email} (
|
||||
<span className="hover:underline">
|
||||
<Link href="/logout">Log out</Link>
|
||||
</span>
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ models/PageGetTranscript.ts
|
||||
models/RtcOffer.ts
|
||||
models/TranscriptTopic.ts
|
||||
models/UpdateTranscript.ts
|
||||
models/UserInfo.ts
|
||||
models/ValidationError.ts
|
||||
models/index.ts
|
||||
runtime.ts
|
||||
|
||||
@@ -55,6 +55,10 @@ export interface V1TranscriptGetAudioRequest {
|
||||
transcriptId: any;
|
||||
}
|
||||
|
||||
export interface V1TranscriptGetAudioMp3Request {
|
||||
transcriptId: any;
|
||||
}
|
||||
|
||||
export interface V1TranscriptGetTopicsRequest {
|
||||
transcriptId: any;
|
||||
}
|
||||
@@ -159,6 +163,14 @@ export class DefaultApi extends runtime.BaseAPI {
|
||||
|
||||
const headerParameters: runtime.HTTPHeaders = {};
|
||||
|
||||
if (this.configuration && this.configuration.accessToken) {
|
||||
// oauth required
|
||||
headerParameters["Authorization"] = await this.configuration.accessToken(
|
||||
"OAuth2AuthorizationCodeBearer",
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
const response = await this.request(
|
||||
{
|
||||
path: `/v1/transcripts/{transcript_id}`.replace(
|
||||
@@ -212,6 +224,14 @@ export class DefaultApi extends runtime.BaseAPI {
|
||||
|
||||
const headerParameters: runtime.HTTPHeaders = {};
|
||||
|
||||
if (this.configuration && this.configuration.accessToken) {
|
||||
// oauth required
|
||||
headerParameters["Authorization"] = await this.configuration.accessToken(
|
||||
"OAuth2AuthorizationCodeBearer",
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
const response = await this.request(
|
||||
{
|
||||
path: `/v1/transcripts/{transcript_id}`.replace(
|
||||
@@ -299,6 +319,61 @@ export class DefaultApi extends runtime.BaseAPI {
|
||||
return await response.value();
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcript Get Audio Mp3
|
||||
*/
|
||||
async v1TranscriptGetAudioMp3Raw(
|
||||
requestParameters: V1TranscriptGetAudioMp3Request,
|
||||
initOverrides?: RequestInit | runtime.InitOverrideFunction,
|
||||
): Promise<runtime.ApiResponse<any>> {
|
||||
if (
|
||||
requestParameters.transcriptId === null ||
|
||||
requestParameters.transcriptId === undefined
|
||||
) {
|
||||
throw new runtime.RequiredError(
|
||||
"transcriptId",
|
||||
"Required parameter requestParameters.transcriptId was null or undefined when calling v1TranscriptGetAudioMp3.",
|
||||
);
|
||||
}
|
||||
|
||||
const queryParameters: any = {};
|
||||
|
||||
const headerParameters: runtime.HTTPHeaders = {};
|
||||
|
||||
const response = await this.request(
|
||||
{
|
||||
path: `/v1/transcripts/{transcript_id}/audio/mp3`.replace(
|
||||
`{${"transcript_id"}}`,
|
||||
encodeURIComponent(String(requestParameters.transcriptId)),
|
||||
),
|
||||
method: "GET",
|
||||
headers: headerParameters,
|
||||
query: queryParameters,
|
||||
},
|
||||
initOverrides,
|
||||
);
|
||||
|
||||
if (this.isJsonMime(response.headers.get("content-type"))) {
|
||||
return new runtime.JSONApiResponse<any>(response);
|
||||
} else {
|
||||
return new runtime.TextApiResponse(response) as any;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcript Get Audio Mp3
|
||||
*/
|
||||
async v1TranscriptGetAudioMp3(
|
||||
requestParameters: V1TranscriptGetAudioMp3Request,
|
||||
initOverrides?: RequestInit | runtime.InitOverrideFunction,
|
||||
): Promise<any> {
|
||||
const response = await this.v1TranscriptGetAudioMp3Raw(
|
||||
requestParameters,
|
||||
initOverrides,
|
||||
);
|
||||
return await response.value();
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcript Get Topics
|
||||
*/
|
||||
@@ -510,6 +585,14 @@ export class DefaultApi extends runtime.BaseAPI {
|
||||
|
||||
headerParameters["Content-Type"] = "application/json";
|
||||
|
||||
if (this.configuration && this.configuration.accessToken) {
|
||||
// oauth required
|
||||
headerParameters["Authorization"] = await this.configuration.accessToken(
|
||||
"OAuth2AuthorizationCodeBearer",
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
const response = await this.request(
|
||||
{
|
||||
path: `/v1/transcripts/{transcript_id}`.replace(
|
||||
@@ -566,6 +649,14 @@ export class DefaultApi extends runtime.BaseAPI {
|
||||
|
||||
headerParameters["Content-Type"] = "application/json";
|
||||
|
||||
if (this.configuration && this.configuration.accessToken) {
|
||||
// oauth required
|
||||
headerParameters["Authorization"] = await this.configuration.accessToken(
|
||||
"OAuth2AuthorizationCodeBearer",
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
const response = await this.request(
|
||||
{
|
||||
path: `/v1/transcripts`,
|
||||
@@ -615,6 +706,14 @@ export class DefaultApi extends runtime.BaseAPI {
|
||||
|
||||
const headerParameters: runtime.HTTPHeaders = {};
|
||||
|
||||
if (this.configuration && this.configuration.accessToken) {
|
||||
// oauth required
|
||||
headerParameters["Authorization"] = await this.configuration.accessToken(
|
||||
"OAuth2AuthorizationCodeBearer",
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
const response = await this.request(
|
||||
{
|
||||
path: `/v1/transcripts`,
|
||||
@@ -643,4 +742,49 @@ export class DefaultApi extends runtime.BaseAPI {
|
||||
);
|
||||
return await response.value();
|
||||
}
|
||||
|
||||
/**
|
||||
* User Me
|
||||
*/
|
||||
async v1UserMeRaw(
|
||||
initOverrides?: RequestInit | runtime.InitOverrideFunction,
|
||||
): Promise<runtime.ApiResponse<any>> {
|
||||
const queryParameters: any = {};
|
||||
|
||||
const headerParameters: runtime.HTTPHeaders = {};
|
||||
|
||||
if (this.configuration && this.configuration.accessToken) {
|
||||
// oauth required
|
||||
headerParameters["Authorization"] = await this.configuration.accessToken(
|
||||
"OAuth2AuthorizationCodeBearer",
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
const response = await this.request(
|
||||
{
|
||||
path: `/v1/me`,
|
||||
method: "GET",
|
||||
headers: headerParameters,
|
||||
query: queryParameters,
|
||||
},
|
||||
initOverrides,
|
||||
);
|
||||
|
||||
if (this.isJsonMime(response.headers.get("content-type"))) {
|
||||
return new runtime.JSONApiResponse<any>(response);
|
||||
} else {
|
||||
return new runtime.TextApiResponse(response) as any;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User Me
|
||||
*/
|
||||
async v1UserMe(
|
||||
initOverrides?: RequestInit | runtime.InitOverrideFunction,
|
||||
): Promise<any> {
|
||||
const response = await this.v1UserMeRaw(initOverrides);
|
||||
return await response.value();
|
||||
}
|
||||
}
|
||||
|
||||
84
www/app/api/models/UserInfo.ts
Normal file
84
www/app/api/models/UserInfo.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* FastAPI
|
||||
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
import { exists, mapValues } from "../runtime";
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface UserInfo
|
||||
*/
|
||||
export interface UserInfo {
|
||||
/**
|
||||
*
|
||||
* @type {any}
|
||||
* @memberof UserInfo
|
||||
*/
|
||||
sub: any | null;
|
||||
/**
|
||||
*
|
||||
* @type {any}
|
||||
* @memberof UserInfo
|
||||
*/
|
||||
email: any | null;
|
||||
/**
|
||||
*
|
||||
* @type {any}
|
||||
* @memberof UserInfo
|
||||
*/
|
||||
emailVerified: any | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a given object implements the UserInfo interface.
|
||||
*/
|
||||
export function instanceOfUserInfo(value: object): boolean {
|
||||
let isInstance = true;
|
||||
isInstance = isInstance && "sub" in value;
|
||||
isInstance = isInstance && "email" in value;
|
||||
isInstance = isInstance && "emailVerified" in value;
|
||||
|
||||
return isInstance;
|
||||
}
|
||||
|
||||
export function UserInfoFromJSON(json: any): UserInfo {
|
||||
return UserInfoFromJSONTyped(json, false);
|
||||
}
|
||||
|
||||
export function UserInfoFromJSONTyped(
|
||||
json: any,
|
||||
ignoreDiscriminator: boolean,
|
||||
): UserInfo {
|
||||
if (json === undefined || json === null) {
|
||||
return json;
|
||||
}
|
||||
return {
|
||||
sub: json["sub"],
|
||||
email: json["email"],
|
||||
emailVerified: json["email_verified"],
|
||||
};
|
||||
}
|
||||
|
||||
export function UserInfoToJSON(value?: UserInfo | null): any {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
sub: value.sub,
|
||||
email: value.email,
|
||||
email_verified: value.emailVerified,
|
||||
};
|
||||
}
|
||||
@@ -8,4 +8,5 @@ export * from "./PageGetTranscript";
|
||||
export * from "./RtcOffer";
|
||||
export * from "./TranscriptTopic";
|
||||
export * from "./UpdateTranscript";
|
||||
export * from "./UserInfo";
|
||||
export * from "./ValidationError";
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import "./styles/globals.scss";
|
||||
import { Roboto } from "next/font/google";
|
||||
|
||||
import Head from "next/head";
|
||||
import { Metadata } from "next";
|
||||
import FiefWrapper from "./(auth)/fiefWrapper";
|
||||
import UserInfo from "./(auth)/userInfo";
|
||||
|
||||
const roboto = Roboto({ subsets: ["latin"], weight: "400" });
|
||||
|
||||
export const metadata = {
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
template: "%s – Reflector",
|
||||
default: "Reflector - AI-Powered Meeting Transcriptions by Monadical",
|
||||
@@ -52,11 +53,22 @@ export const metadata = {
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<Head>
|
||||
<title>Test</title>
|
||||
</Head>
|
||||
<body className={roboto.className + " flex flex-col min-h-screen"}>
|
||||
{children}
|
||||
<FiefWrapper>
|
||||
<div id="container">
|
||||
<div className="flex flex-col items-center h-[100svh] bg-gradient-to-r from-[#8ec5fc30] to-[#e0c3fc42]">
|
||||
<UserInfo />
|
||||
|
||||
<div className="h-[13svh] flex flex-col justify-center items-center">
|
||||
<h1 className="text-5xl font-bold text-blue-500">Reflector</h1>
|
||||
<p className="text-gray-500">
|
||||
Capture The Signal, Not The Noise
|
||||
</p>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</FiefWrapper>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
129
www/app/lib/CustomRecordPlugin.js
Normal file
129
www/app/lib/CustomRecordPlugin.js
Normal file
@@ -0,0 +1,129 @@
|
||||
// Override the startRecording method so we can pass the desired stream
|
||||
// Checkout: https://github.com/katspaugh/wavesurfer.js/blob/fa2bcfe/src/plugins/record.ts
|
||||
|
||||
import RecordPlugin from "wavesurfer.js/dist/plugins/record";
|
||||
|
||||
const MIME_TYPES = [
|
||||
"audio/webm",
|
||||
"audio/wav",
|
||||
"audio/mpeg",
|
||||
"audio/mp4",
|
||||
"audio/mp3",
|
||||
];
|
||||
const findSupportedMimeType = () =>
|
||||
MIME_TYPES.find((mimeType) => MediaRecorder.isTypeSupported(mimeType));
|
||||
|
||||
class CustomRecordPlugin extends RecordPlugin {
|
||||
static create(options) {
|
||||
return new CustomRecordPlugin(options || {});
|
||||
}
|
||||
render(stream) {
|
||||
if (!this.wavesurfer) return () => undefined;
|
||||
|
||||
const container = this.wavesurfer.getWrapper();
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = container.clientWidth;
|
||||
canvas.height = container.clientHeight;
|
||||
canvas.style.zIndex = "10";
|
||||
container.appendChild(canvas);
|
||||
|
||||
const canvasCtx = canvas.getContext("2d");
|
||||
const audioContext = new AudioContext();
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
const analyser = audioContext.createAnalyser();
|
||||
analyser.fftSize = 2 ** 5;
|
||||
source.connect(analyser);
|
||||
const bufferLength = analyser.frequencyBinCount;
|
||||
const dataArray = new Uint8Array(bufferLength);
|
||||
|
||||
let animationId, previousTimeStamp;
|
||||
const DATA_SIZE = 128.0;
|
||||
const BUFFER_SIZE = 2 ** 8;
|
||||
const dataBuffer = new Array(BUFFER_SIZE).fill(DATA_SIZE);
|
||||
|
||||
const drawWaveform = (timeStamp) => {
|
||||
if (!canvasCtx) return;
|
||||
|
||||
analyser.getByteTimeDomainData(dataArray);
|
||||
canvasCtx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
canvasCtx.fillStyle = "#cc3347";
|
||||
|
||||
if (previousTimeStamp === undefined) {
|
||||
previousTimeStamp = timeStamp;
|
||||
dataBuffer.push(Math.min(...dataArray));
|
||||
dataBuffer.splice(0, 1);
|
||||
}
|
||||
const elapsed = timeStamp - previousTimeStamp;
|
||||
if (elapsed > 10) {
|
||||
previousTimeStamp = timeStamp;
|
||||
dataBuffer.push(Math.min(...dataArray));
|
||||
dataBuffer.splice(0, 1);
|
||||
}
|
||||
|
||||
// Drawing
|
||||
const sliceWidth = canvas.width / dataBuffer.length;
|
||||
let x = 0;
|
||||
|
||||
for (let i = 0; i < dataBuffer.length; i++) {
|
||||
const y = (canvas.height * dataBuffer[i]) / (2 * DATA_SIZE);
|
||||
const sliceHeight =
|
||||
((1 - canvas.height) * dataBuffer[i]) / DATA_SIZE + canvas.height;
|
||||
|
||||
canvasCtx.fillRect(x, y, (sliceWidth * 2) / 3, sliceHeight);
|
||||
x += sliceWidth;
|
||||
}
|
||||
|
||||
animationId = requestAnimationFrame(drawWaveform);
|
||||
};
|
||||
|
||||
drawWaveform();
|
||||
|
||||
return () => {
|
||||
if (animationId) {
|
||||
cancelAnimationFrame(animationId);
|
||||
}
|
||||
|
||||
if (source) {
|
||||
source.disconnect();
|
||||
source.mediaStream.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
|
||||
if (audioContext) {
|
||||
audioContext.close();
|
||||
}
|
||||
|
||||
canvas?.remove();
|
||||
};
|
||||
}
|
||||
startRecording(stream) {
|
||||
this.preventInteraction();
|
||||
this.cleanUp();
|
||||
|
||||
const onStop = this.render(stream);
|
||||
const mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType: this.options.mimeType || findSupportedMimeType(),
|
||||
audioBitsPerSecond: this.options.audioBitsPerSecond,
|
||||
});
|
||||
const recordedChunks = [];
|
||||
|
||||
mediaRecorder.addEventListener("dataavailable", (event) => {
|
||||
if (event.data.size > 0) {
|
||||
recordedChunks.push(event.data);
|
||||
}
|
||||
});
|
||||
|
||||
mediaRecorder.addEventListener("stop", () => {
|
||||
onStop();
|
||||
this.loadBlob(recordedChunks, mediaRecorder.mimeType);
|
||||
this.emit("stopRecording");
|
||||
});
|
||||
|
||||
mediaRecorder.start();
|
||||
|
||||
this.emit("startRecording");
|
||||
|
||||
this.mediaRecorder = mediaRecorder;
|
||||
}
|
||||
}
|
||||
|
||||
export default CustomRecordPlugin;
|
||||
129
www/app/lib/custom-plugins/record.js
Normal file
129
www/app/lib/custom-plugins/record.js
Normal file
@@ -0,0 +1,129 @@
|
||||
// Override the startRecording method so we can pass the desired stream
|
||||
// Checkout: https://github.com/katspaugh/wavesurfer.js/blob/fa2bcfe/src/plugins/record.ts
|
||||
|
||||
import RecordPlugin from "wavesurfer.js/dist/plugins/record";
|
||||
|
||||
const MIME_TYPES = [
|
||||
"audio/webm",
|
||||
"audio/wav",
|
||||
"audio/mpeg",
|
||||
"audio/mp4",
|
||||
"audio/mp3",
|
||||
];
|
||||
const findSupportedMimeType = () =>
|
||||
MIME_TYPES.find((mimeType) => MediaRecorder.isTypeSupported(mimeType));
|
||||
|
||||
class CustomRecordPlugin extends RecordPlugin {
|
||||
static create(options) {
|
||||
return new CustomRecordPlugin(options || {});
|
||||
}
|
||||
render(stream) {
|
||||
if (!this.wavesurfer) return () => undefined;
|
||||
|
||||
const container = this.wavesurfer.getWrapper();
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = container.clientWidth;
|
||||
canvas.height = container.clientHeight;
|
||||
canvas.style.zIndex = "10";
|
||||
container.appendChild(canvas);
|
||||
|
||||
const canvasCtx = canvas.getContext("2d");
|
||||
const audioContext = new AudioContext();
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
const analyser = audioContext.createAnalyser();
|
||||
analyser.fftSize = 2 ** 5;
|
||||
source.connect(analyser);
|
||||
const bufferLength = analyser.frequencyBinCount;
|
||||
const dataArray = new Uint8Array(bufferLength);
|
||||
|
||||
let animationId, previousTimeStamp;
|
||||
const DATA_SIZE = 128.0;
|
||||
const BUFFER_SIZE = 2 ** 8;
|
||||
const dataBuffer = new Array(BUFFER_SIZE).fill(DATA_SIZE);
|
||||
|
||||
const drawWaveform = (timeStamp) => {
|
||||
if (!canvasCtx) return;
|
||||
|
||||
analyser.getByteTimeDomainData(dataArray);
|
||||
canvasCtx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
canvasCtx.fillStyle = "#cc3347";
|
||||
|
||||
if (previousTimeStamp === undefined) {
|
||||
previousTimeStamp = timeStamp;
|
||||
dataBuffer.push(Math.min(...dataArray));
|
||||
dataBuffer.splice(0, 1);
|
||||
}
|
||||
const elapsed = timeStamp - previousTimeStamp;
|
||||
if (elapsed > 10) {
|
||||
previousTimeStamp = timeStamp;
|
||||
dataBuffer.push(Math.min(...dataArray));
|
||||
dataBuffer.splice(0, 1);
|
||||
}
|
||||
|
||||
// Drawing
|
||||
const sliceWidth = canvas.width / dataBuffer.length;
|
||||
let x = 0;
|
||||
|
||||
for (let i = 0; i < dataBuffer.length; i++) {
|
||||
const y = (canvas.height * dataBuffer[i]) / (2 * DATA_SIZE);
|
||||
const sliceHeight =
|
||||
((1 - canvas.height) * dataBuffer[i]) / DATA_SIZE + canvas.height;
|
||||
|
||||
canvasCtx.fillRect(x, y, (sliceWidth * 2) / 3, sliceHeight);
|
||||
x += sliceWidth;
|
||||
}
|
||||
|
||||
animationId = requestAnimationFrame(drawWaveform);
|
||||
};
|
||||
|
||||
drawWaveform();
|
||||
|
||||
return () => {
|
||||
if (animationId) {
|
||||
cancelAnimationFrame(animationId);
|
||||
}
|
||||
|
||||
if (source) {
|
||||
source.disconnect();
|
||||
source.mediaStream.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
|
||||
if (audioContext) {
|
||||
audioContext.close();
|
||||
}
|
||||
|
||||
canvas?.remove();
|
||||
};
|
||||
}
|
||||
startRecording(stream) {
|
||||
this.preventInteraction();
|
||||
this.cleanUp();
|
||||
|
||||
const onStop = this.render(stream);
|
||||
const mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType: this.options.mimeType || findSupportedMimeType(),
|
||||
audioBitsPerSecond: this.options.audioBitsPerSecond,
|
||||
});
|
||||
const recordedChunks = [];
|
||||
|
||||
mediaRecorder.addEventListener("dataavailable", (event) => {
|
||||
if (event.data.size > 0) {
|
||||
recordedChunks.push(event.data);
|
||||
}
|
||||
});
|
||||
|
||||
mediaRecorder.addEventListener("stop", () => {
|
||||
onStop();
|
||||
this.loadBlob(recordedChunks, mediaRecorder.mimeType);
|
||||
this.emit("stopRecording");
|
||||
});
|
||||
|
||||
mediaRecorder.start();
|
||||
|
||||
this.emit("startRecording");
|
||||
|
||||
this.mediaRecorder = mediaRecorder;
|
||||
}
|
||||
}
|
||||
|
||||
export default CustomRecordPlugin;
|
||||
12
www/app/lib/custom-plugins/regions.js
Normal file
12
www/app/lib/custom-plugins/regions.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import RegionsPlugin from "wavesurfer.js/dist/plugins/regions";
|
||||
|
||||
class CustomRegionsPlugin extends RegionsPlugin {
|
||||
static create(options) {
|
||||
return new CustomRegionsPlugin(options);
|
||||
}
|
||||
avoidOverlapping(region) {
|
||||
// Prevent overlapping regions
|
||||
}
|
||||
}
|
||||
|
||||
export default CustomRegionsPlugin;
|
||||
50
www/app/lib/fief.ts
Normal file
50
www/app/lib/fief.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
import { Fief, FiefUserInfo } from "@fief/fief";
|
||||
import { FiefAuth, IUserInfoCache } from "@fief/fief/nextjs";
|
||||
|
||||
export const SESSION_COOKIE_NAME = "reflector-auth";
|
||||
|
||||
export const fiefClient = new Fief({
|
||||
baseURL: process.env.FIEF_URL ?? "",
|
||||
clientId: process.env.FIEF_CLIENT_ID ?? "",
|
||||
clientSecret: process.env.FIEF_CLIENT_SECRET ?? "",
|
||||
});
|
||||
|
||||
class MemoryUserInfoCache implements IUserInfoCache {
|
||||
private storage: Record<string, any>;
|
||||
|
||||
constructor() {
|
||||
this.storage = {};
|
||||
}
|
||||
|
||||
async get(id: string): Promise<FiefUserInfo | null> {
|
||||
const userinfo = this.storage[id];
|
||||
if (userinfo) {
|
||||
return userinfo;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async set(id: string, userinfo: FiefUserInfo): Promise<void> {
|
||||
this.storage[id] = userinfo;
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
this.storage[id] = undefined;
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.storage = {};
|
||||
}
|
||||
}
|
||||
|
||||
export const fiefAuth = new FiefAuth({
|
||||
client: fiefClient,
|
||||
sessionCookieName: SESSION_COOKIE_NAME,
|
||||
redirectURI:
|
||||
process.env.NEXT_PUBLIC_AUTH_CALLBACK_URL ||
|
||||
"http://localhost:3000/auth-callback",
|
||||
logoutRedirectURI:
|
||||
process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000",
|
||||
userInfoCache: new MemoryUserInfoCache(),
|
||||
});
|
||||
18
www/app/lib/getApi.ts
Normal file
18
www/app/lib/getApi.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Configuration } from "../api/runtime";
|
||||
import { DefaultApi } from "../api/apis/DefaultApi";
|
||||
|
||||
import { useFiefAccessTokenInfo } from "@fief/fief/nextjs/react";
|
||||
|
||||
export default function getApi(): DefaultApi {
|
||||
const accessTokenInfo = useFiefAccessTokenInfo();
|
||||
|
||||
const apiConfiguration = new Configuration({
|
||||
basePath: process.env.NEXT_PUBLIC_API_URL,
|
||||
accessToken: accessTokenInfo
|
||||
? "Bearer " + accessTokenInfo.access_token
|
||||
: undefined,
|
||||
});
|
||||
const api = new DefaultApi(apiConfiguration);
|
||||
|
||||
return api;
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
export function getRandomNumber(min, max) {
|
||||
export function getRandomNumber(min: number, max: number): number {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
export function SeededRand(seed) {
|
||||
export function SeededRand(seed: number): number {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
return seed / 2 ** 32;
|
||||
}
|
||||
|
||||
export function Mulberry32(seed) {
|
||||
export function Mulberry32(seed: number) {
|
||||
return function () {
|
||||
var t = (seed += 0x6d2b79f5);
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
@@ -1,4 +1,4 @@
|
||||
export const formatTime = (seconds) => {
|
||||
export const formatTime = (seconds: number): string => {
|
||||
let hours = Math.floor(seconds / 3600);
|
||||
let minutes = Math.floor((seconds % 3600) / 60);
|
||||
let secs = Math.floor(seconds % 60);
|
||||
21
www/app/lib/user.ts
Normal file
21
www/app/lib/user.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export async function getCurrentUser(): Promise<any> {
|
||||
try {
|
||||
const response = await fetch("/api/current-user");
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Ensure the data structure is as expected
|
||||
if (data.userinfo && data.access_token_info) {
|
||||
return data;
|
||||
} else {
|
||||
throw new Error("Unexpected data structure");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching the user data:", error);
|
||||
throw error; // or you can return an appropriate fallback or error indicator
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { redirect } from "next/navigation";
|
||||
export default async function Index({ params }) {
|
||||
export default async function Index() {
|
||||
redirect("/transcripts/new");
|
||||
}
|
||||
@@ -3,8 +3,24 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faChevronRight,
|
||||
faChevronDown,
|
||||
faLinkSlash,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { formatTime } from "../lib/time";
|
||||
import ScrollToBottom from "./scrollToBottom";
|
||||
import DisconnectedIndicator from "./disconnectedIndicator";
|
||||
import LiveTrancription from "./liveTranscription";
|
||||
import FinalSummary from "./finalSummary";
|
||||
import { Topic, FinalSummary as FinalSummaryType } from "./webSocketTypes";
|
||||
|
||||
type DashboardProps = {
|
||||
transcriptionText: string;
|
||||
finalSummary: FinalSummaryType;
|
||||
topics: Topic[];
|
||||
disconnected: boolean;
|
||||
useActiveTopic: [
|
||||
Topic | null,
|
||||
React.Dispatch<React.SetStateAction<Topic | null>>,
|
||||
];
|
||||
};
|
||||
|
||||
export function Dashboard({
|
||||
transcriptionText,
|
||||
@@ -12,9 +28,9 @@ export function Dashboard({
|
||||
topics,
|
||||
disconnected,
|
||||
useActiveTopic,
|
||||
}) {
|
||||
}: DashboardProps) {
|
||||
const [activeTopic, setActiveTopic] = useActiveTopic;
|
||||
const [autoscrollEnabled, setAutoscrollEnabled] = useState(true);
|
||||
const [autoscrollEnabled, setAutoscrollEnabled] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoscrollEnabled) scrollToBottom();
|
||||
@@ -22,7 +38,10 @@ export function Dashboard({
|
||||
|
||||
const scrollToBottom = () => {
|
||||
const topicsDiv = document.getElementById("topics-div");
|
||||
topicsDiv.scrollTop = topicsDiv.scrollHeight;
|
||||
|
||||
if (!topicsDiv)
|
||||
console.error("Could not find topics div to scroll to bottom");
|
||||
else topicsDiv.scrollTop = topicsDiv.scrollHeight;
|
||||
};
|
||||
|
||||
const handleScroll = (e) => {
|
||||
@@ -35,18 +54,6 @@ export function Dashboard({
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (seconds) => {
|
||||
let hours = Math.floor(seconds / 3600);
|
||||
let minutes = Math.floor((seconds % 3600) / 60);
|
||||
let secs = Math.floor(seconds % 60);
|
||||
|
||||
let timeString = `${hours > 0 ? hours + ":" : ""}${minutes
|
||||
.toString()
|
||||
.padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
|
||||
|
||||
return timeString;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative h-[64svh] w-3/4 flex flex-col">
|
||||
@@ -58,16 +65,12 @@ export function Dashboard({
|
||||
<div className="w-3/4 font-bold">Topic</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`absolute right-5 w-10 h-10 ${
|
||||
autoscrollEnabled ? "hidden" : "flex"
|
||||
} ${
|
||||
finalSummary ? "top-[49%]" : "bottom-1"
|
||||
} justify-center items-center text-2xl cursor-pointer opacity-70 hover:opacity-100 transition-opacity duration-200 animate-bounce rounded-xl border-slate-400 bg-[#3c82f638] text-[#3c82f6ed]`}
|
||||
onClick={scrollToBottom}
|
||||
>
|
||||
⬇
|
||||
</div>
|
||||
<ScrollToBottom
|
||||
visible={!autoscrollEnabled}
|
||||
hasFinalSummary={finalSummary ? true : false}
|
||||
handleScrollBottom={scrollToBottom}
|
||||
/>
|
||||
|
||||
<div
|
||||
id="topics-div"
|
||||
className="py-2 overflow-y-auto"
|
||||
@@ -106,26 +109,12 @@ export function Dashboard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{finalSummary && (
|
||||
<div className="min-h-[200px] overflow-y-auto mt-2 p-2 bg-white temp-transcription rounded">
|
||||
<h2>Final Summary</h2>
|
||||
<p>{finalSummary.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
{finalSummary.summary && <FinalSummary text={finalSummary.summary} />}
|
||||
</div>
|
||||
|
||||
{disconnected && (
|
||||
<div className="absolute top-0 left-0 w-full h-full bg-black opacity-50 flex justify-center items-center">
|
||||
<div className="text-white text-2xl">
|
||||
<FontAwesomeIcon icon={faLinkSlash} className="mr-2" />
|
||||
Disconnected
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{disconnected && <DisconnectedIndicator />}
|
||||
|
||||
<footer className="h-[7svh] w-full bg-gray-800 text-white text-center py-4 text-2xl">
|
||||
{transcriptionText}
|
||||
</footer>
|
||||
<LiveTrancription text={transcriptionText} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
13
www/app/transcripts/disconnectedIndicator.tsx
Normal file
13
www/app/transcripts/disconnectedIndicator.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faLinkSlash } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
export default function DisconnectedIndicator() {
|
||||
return (
|
||||
<div className="absolute top-0 left-0 w-full h-full bg-black opacity-50 flex justify-center items-center">
|
||||
<div className="text-white text-2xl">
|
||||
<FontAwesomeIcon icon={faLinkSlash} className="mr-2" />
|
||||
Disconnected
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
www/app/transcripts/finalSummary.tsx
Normal file
12
www/app/transcripts/finalSummary.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
type FinalSummaryProps = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
export default function FinalSummary(props: FinalSummaryProps) {
|
||||
return (
|
||||
<div className="min-h-[200px] overflow-y-auto mt-2 p-2 bg-white temp-transcription rounded">
|
||||
<h2>Final Summary</h2>
|
||||
<p>{props.text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
www/app/transcripts/liveTranscription.tsx
Normal file
11
www/app/transcripts/liveTranscription.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
type LiveTranscriptionProps = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
export default function LiveTrancription(props: LiveTranscriptionProps) {
|
||||
return (
|
||||
<div className="h-[7svh] w-full bg-gray-800 text-white text-center py-4 text-2xl">
|
||||
{props.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,11 +6,12 @@ import useWebRTC from "../useWebRTC";
|
||||
import useTranscript from "../useTranscript";
|
||||
import { useWebSockets } from "../useWebSockets";
|
||||
import "../../styles/button.css";
|
||||
import { Topic } from "../webSocketTypes";
|
||||
|
||||
const App = () => {
|
||||
const [stream, setStream] = useState(null);
|
||||
const [disconnected, setDisconnected] = useState(false);
|
||||
const useActiveTopic = useState(null);
|
||||
const [stream, setStream] = useState<MediaStream | null>(null);
|
||||
const [disconnected, setDisconnected] = useState<boolean>(false);
|
||||
const useActiveTopic = useState<Topic | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (process.env.NEXT_PUBLIC_ENV === "development") {
|
||||
@@ -27,12 +28,7 @@ const App = () => {
|
||||
const webSockets = useWebSockets(transcript.response?.id);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center h-[100svh] bg-gradient-to-r from-[#8ec5fc30] to-[#e0c3fc42]">
|
||||
<div className="h-[13svh] flex flex-col justify-center items-center">
|
||||
<h1 className="text-5xl font-bold text-blue-500">Reflector</h1>
|
||||
<p className="text-gray-500">Capture The Signal, Not The Noise</p>
|
||||
</div>
|
||||
|
||||
<>
|
||||
<Recorder
|
||||
setStream={setStream}
|
||||
onStop={() => {
|
||||
@@ -49,11 +45,10 @@ const App = () => {
|
||||
transcriptionText={webSockets.transcriptText}
|
||||
finalSummary={webSockets.finalSummary}
|
||||
topics={webSockets.topics}
|
||||
stream={stream}
|
||||
disconnected={disconnected}
|
||||
useActiveTopic={useActiveTopic}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useRef, useEffect, useState } from "react";
|
||||
|
||||
import WaveSurfer from "wavesurfer.js";
|
||||
import CustomRecordPlugin from "./custom-plugins/record";
|
||||
import CustomRegionsPlugin from "./custom-plugins/regions";
|
||||
import CustomRecordPlugin from "../lib/custom-plugins/record";
|
||||
import CustomRegionsPlugin from "../lib/custom-plugins/regions";
|
||||
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faDownload } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
23
www/app/transcripts/scrollToBottom.tsx
Normal file
23
www/app/transcripts/scrollToBottom.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
type ScrollToBottomProps = {
|
||||
visible: boolean;
|
||||
hasFinalSummary: boolean;
|
||||
handleScrollBottom: () => void;
|
||||
};
|
||||
|
||||
export default function ScrollToBottom(props: ScrollToBottomProps) {
|
||||
return (
|
||||
<div
|
||||
className={`absolute right-5 w-10 h-10 ${
|
||||
props.visible ? "flex" : "hidden"
|
||||
} ${
|
||||
props.hasFinalSummary ? "top-[49%]" : "bottom-1"
|
||||
} justify-center items-center text-2xl cursor-pointer opacity-70 hover:opacity-100 transition-opacity duration-200 animate-bounce rounded-xl border-slate-400 bg-[#3c82f638] text-[#3c82f6ed]`}
|
||||
onClick={() => {
|
||||
props.handleScrollBottom();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
⬇
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,26 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { DefaultApi } from "../api/apis/DefaultApi";
|
||||
import { DefaultApi, V1TranscriptsCreateRequest } from "../api/apis/DefaultApi";
|
||||
import { Configuration } from "../api/runtime";
|
||||
import { GetTranscript } from "../api";
|
||||
import getApi from "../lib/getApi";
|
||||
|
||||
const useTranscript = () => {
|
||||
const [response, setResponse] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
type UseTranscript = {
|
||||
response: GetTranscript | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
createTranscript: () => void;
|
||||
};
|
||||
|
||||
const apiConfiguration = new Configuration({
|
||||
basePath: process.env.NEXT_PUBLIC_API_URL,
|
||||
});
|
||||
const api = new DefaultApi(apiConfiguration);
|
||||
const useTranscript = (): UseTranscript => {
|
||||
const [response, setResponse] = useState<GetTranscript | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const api = getApi();
|
||||
|
||||
const createTranscript = () => {
|
||||
setLoading(true);
|
||||
const requestParameters = {
|
||||
const requestParameters: V1TranscriptsCreateRequest = {
|
||||
createTranscript: {
|
||||
name: "Weekly All-Hands", // Hardcoded for now
|
||||
},
|
||||
@@ -1,28 +1,30 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Peer from "simple-peer";
|
||||
import { DefaultApi } from "../api/apis/DefaultApi";
|
||||
import {
|
||||
DefaultApi,
|
||||
V1TranscriptRecordWebrtcRequest,
|
||||
} from "../api/apis/DefaultApi";
|
||||
import { Configuration } from "../api/runtime";
|
||||
import getApi from "../lib/getApi";
|
||||
|
||||
const useWebRTC = (stream, transcriptId) => {
|
||||
const [data, setData] = useState({
|
||||
peer: null,
|
||||
});
|
||||
const useWebRTC = (
|
||||
stream: MediaStream | null,
|
||||
transcriptId: string | null,
|
||||
): Peer => {
|
||||
const [peer, setPeer] = useState<Peer | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!stream || !transcriptId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const apiConfiguration = new Configuration({
|
||||
basePath: process.env.NEXT_PUBLIC_API_URL,
|
||||
});
|
||||
const api = new DefaultApi(apiConfiguration);
|
||||
const api = getApi();
|
||||
|
||||
let peer = new Peer({ initiator: true, stream: stream });
|
||||
let p: Peer = new Peer({ initiator: true, stream: stream });
|
||||
|
||||
peer.on("signal", (data) => {
|
||||
p.on("signal", (data: any) => {
|
||||
if ("sdp" in data) {
|
||||
const requestParameters = {
|
||||
const requestParameters: V1TranscriptRecordWebrtcRequest = {
|
||||
transcriptId: transcriptId,
|
||||
rtcOffer: {
|
||||
sdp: data.sdp,
|
||||
@@ -33,7 +35,7 @@ const useWebRTC = (stream, transcriptId) => {
|
||||
api
|
||||
.v1TranscriptRecordWebrtc(requestParameters)
|
||||
.then((answer) => {
|
||||
peer.signal(answer);
|
||||
p.signal(answer);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("WebRTC signaling error:", err);
|
||||
@@ -41,17 +43,17 @@ const useWebRTC = (stream, transcriptId) => {
|
||||
}
|
||||
});
|
||||
|
||||
peer.on("connect", () => {
|
||||
p.on("connect", () => {
|
||||
console.log("WebRTC connected");
|
||||
setData((prevData) => ({ ...prevData, peer: peer }));
|
||||
setPeer(p);
|
||||
});
|
||||
|
||||
return () => {
|
||||
peer.destroy();
|
||||
p.destroy();
|
||||
};
|
||||
}, [stream, transcriptId]);
|
||||
|
||||
return data;
|
||||
return peer;
|
||||
};
|
||||
|
||||
export default useWebRTC;
|
||||
@@ -1,10 +1,20 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Topic, FinalSummary, Status } from "./webSocketTypes";
|
||||
|
||||
export const useWebSockets = (transcriptId) => {
|
||||
const [transcriptText, setTranscriptText] = useState("");
|
||||
const [topics, setTopics] = useState([]);
|
||||
const [finalSummary, setFinalSummary] = useState("");
|
||||
const [status, setStatus] = useState("disconnected");
|
||||
type UseWebSockets = {
|
||||
transcriptText: string;
|
||||
topics: Topic[];
|
||||
finalSummary: FinalSummary;
|
||||
status: Status;
|
||||
};
|
||||
|
||||
export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
|
||||
const [transcriptText, setTranscriptText] = useState<string>("");
|
||||
const [topics, setTopics] = useState<Topic[]>([]);
|
||||
const [finalSummary, setFinalSummary] = useState<FinalSummary>({
|
||||
summary: "",
|
||||
});
|
||||
const [status, setStatus] = useState<Status>({ value: "disconnected" });
|
||||
|
||||
useEffect(() => {
|
||||
if (!transcriptId) return;
|
||||
@@ -40,7 +50,7 @@ export const useWebSockets = (transcriptId) => {
|
||||
break;
|
||||
|
||||
case "STATUS":
|
||||
setStatus(message.data.status);
|
||||
setStatus(message.data);
|
||||
break;
|
||||
|
||||
default:
|
||||
19
www/app/transcripts/webSocketTypes.tsx
Normal file
19
www/app/transcripts/webSocketTypes.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
export type Topic = {
|
||||
timestamp: number;
|
||||
title: string;
|
||||
transcript: string;
|
||||
summary: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type Transcript = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type FinalSummary = {
|
||||
summary: string;
|
||||
};
|
||||
|
||||
export type Status = {
|
||||
value: string;
|
||||
};
|
||||
20
www/middleware.ts
Normal file
20
www/middleware.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { fiefAuth } from "./app/lib/fief";
|
||||
|
||||
const authMiddleware = fiefAuth.middleware([
|
||||
{
|
||||
matcher: "/private",
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
matcher: "/castles/:path*",
|
||||
parameters: {
|
||||
permissions: ["castles:read"],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
return authMiddleware(request);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ const nextConfig = {
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
||||
// Sentry content below
|
||||
// Injected content via Sentry wizard below
|
||||
|
||||
const { withSentryConfig } = require("@sentry/nextjs");
|
||||
|
||||
|
||||
@@ -11,10 +11,11 @@
|
||||
"openapi": "openapi-generator-cli generate -i http://localhost:1250/openapi.json -g typescript-fetch -o app/api && yarn format"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fief/fief": "^0.13.5",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.4.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.4.0",
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@sentry/nextjs": "^7.61.0",
|
||||
"@sentry/nextjs": "^7.64.0",
|
||||
"autoprefixer": "10.4.14",
|
||||
"axios": "^1.4.0",
|
||||
"fontawesome": "^5.6.3",
|
||||
|
||||
3
www/pages/api/current-user.ts
Normal file
3
www/pages/api/current-user.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { fiefAuth } from "../../app/lib/fief";
|
||||
|
||||
export default fiefAuth.currentUser();
|
||||
7
www/pages/forbidden.tsx
Normal file
7
www/pages/forbidden.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextPage } from "next";
|
||||
|
||||
const Forbidden: NextPage = () => {
|
||||
return <h2>Sorry, you are not authorized to access this page.</h2>;
|
||||
};
|
||||
|
||||
export default Forbidden;
|
||||
174
www/yarn.lock
174
www/yarn.lock
@@ -14,6 +14,16 @@
|
||||
dependencies:
|
||||
regenerator-runtime "^0.14.0"
|
||||
|
||||
"@fief/fief@^0.13.5":
|
||||
version "0.13.5"
|
||||
resolved "https://registry.yarnpkg.com/@fief/fief/-/fief-0.13.5.tgz#01ac833ddff0b84f2e1737cc168721568b0e7a39"
|
||||
integrity sha512-st4+/Rc1rw18B6RtqLmC6t9k0pnYLG7AqMe0JYjKYcamCNnABwl198ZJt6HShtaZKI5maHsh99UoCA3MqpbWsQ==
|
||||
dependencies:
|
||||
encoding "^0.1.13"
|
||||
jose "^4.6.0"
|
||||
node-fetch "^2.6.7"
|
||||
path-to-regexp "^6.2.1"
|
||||
|
||||
"@fortawesome/fontawesome-common-types@6.4.0":
|
||||
version "6.4.0"
|
||||
resolved "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.0.tgz"
|
||||
@@ -252,26 +262,26 @@
|
||||
estree-walker "^2.0.2"
|
||||
picomatch "^2.3.1"
|
||||
|
||||
"@sentry-internal/tracing@7.61.0":
|
||||
version "7.61.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry-internal/tracing/-/tracing-7.61.0.tgz#5a0dd4a9a0b41f2e22904430f3fe0216f36ee086"
|
||||
integrity sha512-zTr+MXEG4SxNxif42LIgm2RQn+JRXL2NuGhRaKSD2i4lXKFqHVGlVdoWqY5UfqnnJPokiTWIj9ejR8I5HV8Ogw==
|
||||
"@sentry-internal/tracing@7.64.0":
|
||||
version "7.64.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry-internal/tracing/-/tracing-7.64.0.tgz#3e110473b8edf805b799cc91d6ee592830237bb4"
|
||||
integrity sha512-1XE8W6ki7hHyBvX9hfirnGkKDBKNq3bDJyXS86E0bYVDl94nvbRM9BD9DHsCFetqYkVm1yDGEK+6aUVs4CztoQ==
|
||||
dependencies:
|
||||
"@sentry/core" "7.61.0"
|
||||
"@sentry/types" "7.61.0"
|
||||
"@sentry/utils" "7.61.0"
|
||||
"@sentry/core" "7.64.0"
|
||||
"@sentry/types" "7.64.0"
|
||||
"@sentry/utils" "7.64.0"
|
||||
tslib "^2.4.1 || ^1.9.3"
|
||||
|
||||
"@sentry/browser@7.61.0":
|
||||
version "7.61.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-7.61.0.tgz#04f4122e444d8b5ffefed97af3cde2bc1c71bb80"
|
||||
integrity sha512-IGEkJZRP16Oe5CkXkmhU3QdV5RugW6Vds16yJFFYsgp87NprWtRZgqzldFDYkINStfBHVdctj/Rh/ZrLf8QlkQ==
|
||||
"@sentry/browser@7.64.0":
|
||||
version "7.64.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-7.64.0.tgz#76db08a5d32ffe7c5aa907f258e6c845ce7f10d7"
|
||||
integrity sha512-lB2IWUkZavEDclxfLBp554dY10ZNIEvlDZUWWathW+Ws2wRb6PNLtuPUNu12R7Q7z0xpkOLrM1kRNN0OdldgKA==
|
||||
dependencies:
|
||||
"@sentry-internal/tracing" "7.61.0"
|
||||
"@sentry/core" "7.61.0"
|
||||
"@sentry/replay" "7.61.0"
|
||||
"@sentry/types" "7.61.0"
|
||||
"@sentry/utils" "7.61.0"
|
||||
"@sentry-internal/tracing" "7.64.0"
|
||||
"@sentry/core" "7.64.0"
|
||||
"@sentry/replay" "7.64.0"
|
||||
"@sentry/types" "7.64.0"
|
||||
"@sentry/utils" "7.64.0"
|
||||
tslib "^2.4.1 || ^1.9.3"
|
||||
|
||||
"@sentry/cli@^1.74.6":
|
||||
@@ -286,88 +296,88 @@
|
||||
proxy-from-env "^1.1.0"
|
||||
which "^2.0.2"
|
||||
|
||||
"@sentry/core@7.61.0":
|
||||
version "7.61.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-7.61.0.tgz#0de4f73055bd156c5c0cbac50bb814b272567188"
|
||||
integrity sha512-zl0ZKRjIoYJQWYTd3K/U6zZfS4GDY9yGd2EH4vuYO4kfYtEp/nJ8A+tfAeDo0c9FGxZ0Q+5t5F4/SfwbgyyQzg==
|
||||
"@sentry/core@7.64.0":
|
||||
version "7.64.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-7.64.0.tgz#9d61cdc29ba299dedbdcbe01cfadf94bd0b7df48"
|
||||
integrity sha512-IzmEyl5sNG7NyEFiyFHEHC+sizsZp9MEw1+RJRLX6U5RITvcsEgcajSkHQFafaBPzRrcxZMdm47Cwhl212LXcw==
|
||||
dependencies:
|
||||
"@sentry/types" "7.61.0"
|
||||
"@sentry/utils" "7.61.0"
|
||||
"@sentry/types" "7.64.0"
|
||||
"@sentry/utils" "7.64.0"
|
||||
tslib "^2.4.1 || ^1.9.3"
|
||||
|
||||
"@sentry/integrations@7.61.0":
|
||||
version "7.61.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/integrations/-/integrations-7.61.0.tgz#49c97a59ceb0438bd5ec070415d95f8c6c708d5f"
|
||||
integrity sha512-NEQ+CatBfUM1TmA4FOOyHfsMvSIwSg4pA55Lxiq9quDykzkEtrXFzUfFpZbTunz4cegG8hucPOqbzKFrDPfGjQ==
|
||||
"@sentry/integrations@7.64.0":
|
||||
version "7.64.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/integrations/-/integrations-7.64.0.tgz#a392ddeebeec0c08ae5ca1f544c80ab15977fe10"
|
||||
integrity sha512-6gbSGiruOifAmLtXw//Za19GWiL5qugDMEFxSvc5WrBWb+A8UK+foPn3K495OcivLS68AmqAQCUGb+6nlVowwA==
|
||||
dependencies:
|
||||
"@sentry/types" "7.61.0"
|
||||
"@sentry/utils" "7.61.0"
|
||||
"@sentry/types" "7.64.0"
|
||||
"@sentry/utils" "7.64.0"
|
||||
localforage "^1.8.1"
|
||||
tslib "^2.4.1 || ^1.9.3"
|
||||
|
||||
"@sentry/nextjs@^7.61.0":
|
||||
version "7.61.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/nextjs/-/nextjs-7.61.0.tgz#5a30faa6fb04d9148853edbb5c148dd522126097"
|
||||
integrity sha512-zSEcAITqVmJpR4hhah1jUyCzm/hjlq9vjmO6BmTnQjr84OgOdeKJGWtRdktXId+9zzHdCOehs/JPtmO7y+yG6Q==
|
||||
"@sentry/nextjs@^7.64.0":
|
||||
version "7.64.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/nextjs/-/nextjs-7.64.0.tgz#5c0bd7ccc6637e0b925dec25ca247dcb8476663c"
|
||||
integrity sha512-hKlIQpFugdRlWj0wcEG9I8JyVm/osdsE72zwMBGnmCw/jf7U63vjOjfxMe/gRuvllCf/AvoGHEkR5jPufcO+bw==
|
||||
dependencies:
|
||||
"@rollup/plugin-commonjs" "24.0.0"
|
||||
"@sentry/core" "7.61.0"
|
||||
"@sentry/integrations" "7.61.0"
|
||||
"@sentry/node" "7.61.0"
|
||||
"@sentry/react" "7.61.0"
|
||||
"@sentry/types" "7.61.0"
|
||||
"@sentry/utils" "7.61.0"
|
||||
"@sentry/core" "7.64.0"
|
||||
"@sentry/integrations" "7.64.0"
|
||||
"@sentry/node" "7.64.0"
|
||||
"@sentry/react" "7.64.0"
|
||||
"@sentry/types" "7.64.0"
|
||||
"@sentry/utils" "7.64.0"
|
||||
"@sentry/webpack-plugin" "1.20.0"
|
||||
chalk "3.0.0"
|
||||
rollup "2.78.0"
|
||||
stacktrace-parser "^0.1.10"
|
||||
tslib "^2.4.1 || ^1.9.3"
|
||||
|
||||
"@sentry/node@7.61.0":
|
||||
version "7.61.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/node/-/node-7.61.0.tgz#1309330f2ad136af532ad2a03b2a312e885705de"
|
||||
integrity sha512-oTCqD/h92uvbRCrtCdiAqN6Mfe3vF7ywVHZ8Nq3hHmJp6XadUT+fCBwNQ7rjMyqJAOYAnx/vp6iN9n8C5qcYZQ==
|
||||
"@sentry/node@7.64.0":
|
||||
version "7.64.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/node/-/node-7.64.0.tgz#c6f7a67c1442324298f0525e7191bc18572ee1ce"
|
||||
integrity sha512-wRi0uTnp1WSa83X2yLD49tV9QPzGh5e42IKdIDBiQ7lV9JhLILlyb34BZY1pq6p4dp35yDasDrP3C7ubn7wo6A==
|
||||
dependencies:
|
||||
"@sentry-internal/tracing" "7.61.0"
|
||||
"@sentry/core" "7.61.0"
|
||||
"@sentry/types" "7.61.0"
|
||||
"@sentry/utils" "7.61.0"
|
||||
"@sentry-internal/tracing" "7.64.0"
|
||||
"@sentry/core" "7.64.0"
|
||||
"@sentry/types" "7.64.0"
|
||||
"@sentry/utils" "7.64.0"
|
||||
cookie "^0.4.1"
|
||||
https-proxy-agent "^5.0.0"
|
||||
lru_map "^0.3.3"
|
||||
tslib "^2.4.1 || ^1.9.3"
|
||||
|
||||
"@sentry/react@7.61.0":
|
||||
version "7.61.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/react/-/react-7.61.0.tgz#21dc8eb5168fdb45f994e62738313b50c710d6a4"
|
||||
integrity sha512-17ZPDdzx3hzJSHsVFAiw4hUT701LUVIcm568q38sPlSUmnOmNmPeHx/xcQkuxMoVsw/xgf/82B/BKKnIP5/diA==
|
||||
"@sentry/react@7.64.0":
|
||||
version "7.64.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/react/-/react-7.64.0.tgz#edee24ac232990204e0fb43dd83994642d4b0f54"
|
||||
integrity sha512-wOyJUQi7OoT1q+F/fVVv1fzbyO4OYbTu6m1DliLOGQPGEHPBsgPc722smPIExd1/rAMK/FxOuNN5oNhubH8nhg==
|
||||
dependencies:
|
||||
"@sentry/browser" "7.61.0"
|
||||
"@sentry/types" "7.61.0"
|
||||
"@sentry/utils" "7.61.0"
|
||||
"@sentry/browser" "7.64.0"
|
||||
"@sentry/types" "7.64.0"
|
||||
"@sentry/utils" "7.64.0"
|
||||
hoist-non-react-statics "^3.3.2"
|
||||
tslib "^2.4.1 || ^1.9.3"
|
||||
|
||||
"@sentry/replay@7.61.0":
|
||||
version "7.61.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/replay/-/replay-7.61.0.tgz#f816d6a2fc7511877efee2e328681d659433d147"
|
||||
integrity sha512-1ugk0yZssOPkSg6uTVcysjxlBydycXiOgV0PCU7DsXCFOV1ua5YpyPZFReTz9iFTtwD0LwGFM1LW9wJeQ67Fzg==
|
||||
"@sentry/replay@7.64.0":
|
||||
version "7.64.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/replay/-/replay-7.64.0.tgz#bdf09b0c4712f9dc6b24b3ebefa55a4ac76708e6"
|
||||
integrity sha512-alaMCZDZhaAVmEyiUnszZnvfdbiZx5MmtMTGrlDd7tYq3K5OA9prdLqqlmfIJYBfYtXF3lD0iZFphOZQD+4CIw==
|
||||
dependencies:
|
||||
"@sentry/core" "7.61.0"
|
||||
"@sentry/types" "7.61.0"
|
||||
"@sentry/utils" "7.61.0"
|
||||
"@sentry/core" "7.64.0"
|
||||
"@sentry/types" "7.64.0"
|
||||
"@sentry/utils" "7.64.0"
|
||||
|
||||
"@sentry/types@7.61.0":
|
||||
version "7.61.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.61.0.tgz#4243b5ef4658f6b0673bc4372c90e6ec920f78d8"
|
||||
integrity sha512-/GLlIBNR35NKPE/SfWi9W10dK9hE8qTShzsuPVn5wAJxpT3Lb4+dkwmKCTLUYxdkmvRDEudkfOxgalsfQGTAWA==
|
||||
"@sentry/types@7.64.0":
|
||||
version "7.64.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.64.0.tgz#21fc545ea05c3c8c4c3e518583eca1a8c5429506"
|
||||
integrity sha512-LqjQprWXjUFRmzIlUjyA+KL+38elgIYmAeoDrdyNVh8MK5IC1W2Lh1Q87b4yOiZeMiIhIVNBd7Ecoh2rodGrGA==
|
||||
|
||||
"@sentry/utils@7.61.0":
|
||||
version "7.61.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-7.61.0.tgz#16944afb2b851af045fb528c0c35b7dea3e1cd3b"
|
||||
integrity sha512-jfj14d0XBFiCU0G6dZZ12SizATiF5Mt4stBGzkM5iS9nXFj8rh1oTT7/p+aZoYzP2JTF+sDzkNjWxyKZkcTo0Q==
|
||||
"@sentry/utils@7.64.0":
|
||||
version "7.64.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-7.64.0.tgz#6fe3ce9a56d3433ed32119f914907361a54cc184"
|
||||
integrity sha512-HRlM1INzK66Gt+F4vCItiwGKAng4gqzCR4C5marsL3qv6SrKH98dQnCGYgXluSWaaa56h97FRQu7TxCk6jkSvQ==
|
||||
dependencies:
|
||||
"@sentry/types" "7.61.0"
|
||||
"@sentry/types" "7.64.0"
|
||||
tslib "^2.4.1 || ^1.9.3"
|
||||
|
||||
"@sentry/webpack-plugin@1.20.0":
|
||||
@@ -868,6 +878,13 @@ emoji-regex@^8.0.0:
|
||||
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
|
||||
integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
|
||||
|
||||
encoding@^0.1.13:
|
||||
version "0.1.13"
|
||||
resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9"
|
||||
integrity sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==
|
||||
dependencies:
|
||||
iconv-lite "^0.6.2"
|
||||
|
||||
err-code@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.npmjs.org/err-code/-/err-code-3.0.1.tgz"
|
||||
@@ -1108,6 +1125,13 @@ iconv-lite@^0.4.24:
|
||||
dependencies:
|
||||
safer-buffer ">= 2.1.2 < 3"
|
||||
|
||||
iconv-lite@^0.6.2:
|
||||
version "0.6.3"
|
||||
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501"
|
||||
integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==
|
||||
dependencies:
|
||||
safer-buffer ">= 2.1.2 < 3.0.0"
|
||||
|
||||
ieee754@^1.1.13, ieee754@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz"
|
||||
@@ -1247,6 +1271,11 @@ jiti@^1.18.2:
|
||||
resolved "https://registry.npmjs.org/jiti/-/jiti-1.19.1.tgz"
|
||||
integrity sha512-oVhqoRDaBXf7sjkll95LHVS6Myyyb1zaunVwk4Z0+WPSW4gjS0pl01zYKHScTuyEhQsFxV5L4DR5r+YqSyqyyg==
|
||||
|
||||
jose@^4.6.0:
|
||||
version "4.14.4"
|
||||
resolved "https://registry.yarnpkg.com/jose/-/jose-4.14.4.tgz#59e09204e2670c3164ee24cbfe7115c6f8bff9ca"
|
||||
integrity sha512-j8GhLiKmUAh+dsFXlX1aJCbt5KMibuKb+d7j1JaOJG6s2UjX1PQlW+OKB/sD4a/5ZYF4RcmYmLSndOoU3Lt/3g==
|
||||
|
||||
"js-tokens@^3.0.0 || ^4.0.0":
|
||||
version "4.0.0"
|
||||
resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz"
|
||||
@@ -1529,6 +1558,11 @@ path-to-regexp@3.2.0:
|
||||
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-3.2.0.tgz#fa7877ecbc495c601907562222453c43cc204a5f"
|
||||
integrity sha512-jczvQbCUS7XmS7o+y1aEO9OBVFeZBQ1MDSEqmO7xSoPgOPoowY/SxLpZ6Vh97/8qHZOteiCKb7gkG9gA2ZUxJA==
|
||||
|
||||
path-to-regexp@^6.2.1:
|
||||
version "6.2.1"
|
||||
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.2.1.tgz#d54934d6798eb9e5ef14e7af7962c945906918e5"
|
||||
integrity sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==
|
||||
|
||||
picocolors@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz"
|
||||
@@ -1786,7 +1820,7 @@ safe-buffer@^5.1.0, safe-buffer@~5.2.0:
|
||||
resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz"
|
||||
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
|
||||
|
||||
"safer-buffer@>= 2.1.2 < 3":
|
||||
"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0":
|
||||
version "2.1.2"
|
||||
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
|
||||
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
|
||||
|
||||
Reference in New Issue
Block a user