Merge branch 'main' into jose/ui

This commit is contained in:
Jose B
2023-08-22 13:50:05 -05:00
66 changed files with 1615 additions and 315 deletions

View File

@@ -1,6 +1,6 @@
name: Deploy to Amazon ECS name: Deploy to Amazon ECS
on: [deployment, workflow_dispatch] on: [workflow_dispatch]
env: env:
# 384658522150.dkr.ecr.us-east-1.amazonaws.com/reflector # 384658522150.dkr.ecr.us-east-1.amazonaws.com/reflector

View File

@@ -29,3 +29,11 @@ repos:
hooks: hooks:
- id: black - id: black
files: ^server/(reflector|tests)/ 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
View File

@@ -178,3 +178,4 @@ audio_*.wav
# ignore local database # ignore local database
reflector.sqlite3 reflector.sqlite3
data/

View File

@@ -11,6 +11,29 @@
#DATABASE_URL=postgresql://reflector:reflector@localhost:5432/reflector #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 ## Transcription backend
## ##
@@ -45,8 +68,8 @@
## llm backend implementation ## llm backend implementation
## ======================================================= ## =======================================================
## Use oobagooda (default) ## Use oobabooga (default)
#LLM_BACKEND=oobagooda #LLM_BACKEND=oobabooga
#LLM_URL=http://xxx:7860/api/generate/v1 #LLM_URL=http://xxx:7860/api/generate/v1
## Using serverless modal.com (require reflector-gpu-modal deployed) ## Using serverless modal.com (require reflector-gpu-modal deployed)

View File

@@ -3,14 +3,15 @@ Reflector GPU backend - LLM
=========================== ===========================
""" """
import json
import os 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
LLM_MODEL: str = "lmsys/vicuna-13b-v1.5" 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_TORCH_DTYPE: str = "bfloat16"
LLM_MAX_NEW_TOKENS: int = 300 LLM_MAX_NEW_TOKENS: int = 300
@@ -49,6 +50,8 @@ llm_image = (
"torch", "torch",
"sentencepiece", "sentencepiece",
"protobuf", "protobuf",
"jsonformer==0.12.0",
"accelerate==0.21.0",
"einops==0.6.1", "einops==0.6.1",
"hf-transfer~=0.1", "hf-transfer~=0.1",
"huggingface_hub==0.16.4", "huggingface_hub==0.16.4",
@@ -81,6 +84,7 @@ class LLM:
# generation configuration # generation configuration
print("Instance llm generation config") 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 model.config.max_new_tokens = LLM_MAX_NEW_TOKENS
gen_cfg = GenerationConfig.from_model_config(model.config) gen_cfg = GenerationConfig.from_model_config(model.config)
gen_cfg.max_new_tokens = LLM_MAX_NEW_TOKENS gen_cfg.max_new_tokens = LLM_MAX_NEW_TOKENS
@@ -107,8 +111,22 @@ class LLM:
return {"status": "ok"} return {"status": "ok"}
@method() @method()
def generate(self, prompt: str): def generate(self, prompt: str, schema: str = None):
print(f"Generate {prompt=}") print(f"Generate {prompt=}")
# If a schema is given, conform to schema
if schema:
print(f"Schema {schema=}")
import jsonformer
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 # tokenize prompt
input_ids = self.tokenizer.encode(prompt, return_tensors="pt").to( input_ids = self.tokenizer.encode(prompt, return_tensors="pt").to(
self.model.device self.model.device
@@ -135,9 +153,9 @@ class LLM:
) )
@asgi_app() @asgi_app()
def web(): def web():
from fastapi import FastAPI, HTTPException, status, Depends from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from pydantic import BaseModel from pydantic import BaseModel, Field
llmstub = LLM() llmstub = LLM()
@@ -154,11 +172,15 @@ def web():
class LLMRequest(BaseModel): class LLMRequest(BaseModel):
prompt: str prompt: str
schema_: Optional[dict] = Field(None, alias="schema")
@app.post("/llm", dependencies=[Depends(apikey_auth)]) @app.post("/llm", dependencies=[Depends(apikey_auth)])
async def llm( async def llm(
req: LLMRequest, req: LLMRequest,
): ):
if req.schema_:
func = llmstub.generate.spawn(prompt=req.prompt, schema=json.dumps(req.schema_))
else:
func = llmstub.generate.spawn(prompt=req.prompt) func = llmstub.generate.spawn(prompt=req.prompt)
result = func.get() result = func.get()
return result return result

View File

@@ -3,11 +3,11 @@ Reflector GPU backend - transcriber
=================================== ===================================
""" """
import tempfile
import os import os
from modal import Image, method, Stub, asgi_app, Secret import tempfile
from pydantic import BaseModel
from modal import Image, Secret, Stub, asgi_app, method
from pydantic import BaseModel
# Whisper # Whisper
WHISPER_MODEL: str = "large-v2" WHISPER_MODEL: str = "large-v2"
@@ -15,6 +15,9 @@ WHISPER_COMPUTE_TYPE: str = "float16"
WHISPER_NUM_WORKERS: int = 1 WHISPER_NUM_WORKERS: int = 1
WHISPER_CACHE_DIR: str = "/cache/whisper" WHISPER_CACHE_DIR: str = "/cache/whisper"
# Translation Model
TRANSLATION_MODEL = "facebook/m2m100_418M"
stub = Stub(name="reflector-transcriber") stub = Stub(name="reflector-transcriber")
@@ -31,6 +34,9 @@ whisper_image = (
"faster-whisper", "faster-whisper",
"requests", "requests",
"torch", "torch",
"transformers",
"sentencepiece",
"protobuf",
) )
.run_function(download_whisper) .run_function(download_whisper)
.env( .env(
@@ -51,17 +57,21 @@ whisper_image = (
) )
class Whisper: class Whisper:
def __enter__(self): def __enter__(self):
import torch
import faster_whisper import faster_whisper
import torch
from transformers import M2M100ForConditionalGeneration, M2M100Tokenizer
self.use_gpu = torch.cuda.is_available() self.use_gpu = torch.cuda.is_available()
device = "cuda" if self.use_gpu else "cpu" self.device = "cuda" if self.use_gpu else "cpu"
self.model = faster_whisper.WhisperModel( self.model = faster_whisper.WhisperModel(
WHISPER_MODEL, WHISPER_MODEL,
device=device, device=self.device,
compute_type=WHISPER_COMPUTE_TYPE, compute_type=WHISPER_COMPUTE_TYPE,
num_workers=WHISPER_NUM_WORKERS, num_workers=WHISPER_NUM_WORKERS,
) )
self.translation_model = M2M100ForConditionalGeneration.from_pretrained(TRANSLATION_MODEL).to(self.device)
self.translation_tokenizer = M2M100Tokenizer.from_pretrained(TRANSLATION_MODEL)
@method() @method()
def warmup(self): def warmup(self):
@@ -72,28 +82,30 @@ class Whisper:
self, self,
audio_data: str, audio_data: str,
audio_suffix: str, audio_suffix: str,
timestamp: float = 0, source_language: str,
language: str = "en", target_language: str,
timestamp: float = 0
): ):
with tempfile.NamedTemporaryFile("wb+", suffix=f".{audio_suffix}") as fp: with tempfile.NamedTemporaryFile("wb+", suffix=f".{audio_suffix}") as fp:
fp.write(audio_data) fp.write(audio_data)
segments, _ = self.model.transcribe( segments, _ = self.model.transcribe(
fp.name, fp.name,
language=language, language=source_language,
beam_size=5, beam_size=5,
word_timestamps=True, word_timestamps=True,
vad_filter=True, vad_filter=True,
vad_parameters={"min_silence_duration_ms": 500}, vad_parameters={"min_silence_duration_ms": 500},
) )
transcript = "" multilingual_transcript = {}
transcript_source_lang = ""
words = [] words = []
if segments: if segments:
segments = list(segments) segments = list(segments)
for segment in segments: for segment in segments:
transcript += segment.text transcript_source_lang += segment.text
for word in segment.words: for word in segment.words:
words.append( words.append(
{ {
@@ -102,9 +114,24 @@ class Whisper:
"end": round(timestamp + word.end, 3), "end": round(timestamp + word.end, 3),
} }
) )
multilingual_transcript[source_language] = transcript_source_lang
if target_language != source_language:
self.translation_tokenizer.src_lang = source_language
forced_bos_token_id = self.translation_tokenizer.get_lang_id(target_language)
encoded_transcript = self.translation_tokenizer(transcript_source_lang, return_tensors="pt").to(self.device)
generated_tokens = self.translation_model.generate(
**encoded_transcript,
forced_bos_token_id=forced_bos_token_id
)
result = self.translation_tokenizer.batch_decode(generated_tokens, skip_special_tokens=True)
translation = result[0].strip()
multilingual_transcript[target_language] = translation
return { return {
"text": transcript, "text": multilingual_transcript,
"words": words, "words": words
} }
@@ -122,7 +149,7 @@ class Whisper:
) )
@asgi_app() @asgi_app()
def web(): def web():
from fastapi import FastAPI, UploadFile, Form, Depends, HTTPException, status from fastapi import Depends, FastAPI, Form, HTTPException, UploadFile, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from typing_extensions import Annotated from typing_extensions import Annotated
@@ -131,6 +158,7 @@ def web():
app = FastAPI() app = FastAPI()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
supported_audio_file_types = ["wav", "mp3", "ogg", "flac"]
def apikey_auth(apikey: str = Depends(oauth2_scheme)): def apikey_auth(apikey: str = Depends(oauth2_scheme)):
if apikey != os.environ["REFLECTOR_GPU_APIKEY"]: if apikey != os.environ["REFLECTOR_GPU_APIKEY"]:
@@ -140,28 +168,26 @@ def web():
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
class TranscriptionRequest(BaseModel):
timestamp: float = 0
language: str = "en"
class TranscriptResponse(BaseModel): class TranscriptResponse(BaseModel):
result: str result: dict
@app.post("/transcribe", dependencies=[Depends(apikey_auth)]) @app.post("/transcribe", dependencies=[Depends(apikey_auth)])
async def transcribe( async def transcribe(
file: UploadFile, file: UploadFile,
timestamp: Annotated[float, Form()] = 0, timestamp: Annotated[float, Form()] = 0,
language: Annotated[str, Form()] = "en", source_language: Annotated[str, Form()] = "en",
): target_language: Annotated[str, Form()] = "en"
) -> TranscriptResponse:
audio_data = await file.read() audio_data = await file.read()
audio_suffix = file.filename.split(".")[-1] audio_suffix = file.filename.split(".")[-1]
assert audio_suffix in ["wav", "mp3", "ogg", "flac"] assert audio_suffix in supported_audio_file_types
func = transcriberstub.transcribe_segment.spawn( func = transcriberstub.transcribe_segment.spawn(
audio_data=audio_data, audio_data=audio_data,
audio_suffix=audio_suffix, audio_suffix=audio_suffix,
language=language, source_language=source_language,
timestamp=timestamp, target_language=target_language,
timestamp=timestamp
) )
result = func.get() result = func.get()
return result return result

66
server/poetry.lock generated
View File

@@ -930,6 +930,23 @@ mysql = ["aiomysql"]
postgresql = ["asyncpg"] postgresql = ["asyncpg"]
sqlite = ["aiosqlite"] 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]] [[package]]
name = "dnspython" name = "dnspython"
version = "2.4.1" version = "2.4.1"
@@ -1022,6 +1039,28 @@ tokenizers = "==0.13.*"
conversion = ["transformers[torch] (>=4.23)"] conversion = ["transformers[torch] (>=4.23)"]
dev = ["black (==23.*)", "flake8 (==6.*)", "isort (==5.*)", "pytest (==7.*)"] 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]] [[package]]
name = "filelock" name = "filelock"
version = "3.12.2" version = "3.12.2"
@@ -1530,6 +1569,20 @@ files = [
{file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, {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]] [[package]]
name = "levenshtein" name = "levenshtein"
version = "0.21.1" version = "0.21.1"
@@ -1662,6 +1715,17 @@ win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""}
[package.extras] [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)"] 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]] [[package]]
name = "mpmath" name = "mpmath"
version = "1.3.0" version = "1.3.0"
@@ -3234,4 +3298,4 @@ multidict = ">=4.0"
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.11" python-versions = "^3.11"
content-hash = "ea523f9b74581a7867097a6249d416d8836f4daaf33fde65ea343e4d3502c71c" content-hash = "d84edfea8ac7a849340af8eb5db47df9c13a7cc1c640062ebedb2a808be0de4e"

View File

@@ -25,6 +25,7 @@ httpx = "^0.24.1"
fastapi-pagination = "^0.12.6" fastapi-pagination = "^0.12.6"
databases = {extras = ["aiosqlite", "asyncpg"], version = "^0.7.0"} databases = {extras = ["aiosqlite", "asyncpg"], version = "^0.7.0"}
sqlalchemy = "<1.5" sqlalchemy = "<1.5"
fief-client = {extras = ["fastapi"], version = "^0.17.0"}
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]

View File

@@ -1,14 +1,17 @@
from contextlib import asynccontextmanager
import reflector.auth # noqa
import reflector.db # noqa
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi_pagination import add_pagination
from fastapi.routing import APIRoute from fastapi.routing import APIRoute
import reflector.db # noqa from fastapi_pagination import add_pagination
from reflector.views.rtc_offer import router as rtc_offer_router from reflector.events import subscribers_shutdown, subscribers_startup
from reflector.views.transcripts import router as transcripts_router
from reflector.events import subscribers_startup, subscribers_shutdown
from reflector.logger import logger from reflector.logger import logger
from reflector.settings import settings 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: try:
import sentry_sdk import sentry_sdk
@@ -49,6 +52,7 @@ app.add_middleware(
# register views # register views
app.include_router(rtc_offer_router) app.include_router(rtc_offer_router)
app.include_router(transcripts_router, prefix="/v1") app.include_router(transcripts_router, prefix="/v1")
app.include_router(user_router, prefix="/v1")
add_pagination(app) add_pagination(app)

View 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

View 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)

View 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

View File

@@ -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 importlib
import json import json
import re 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: class LLM:
@@ -20,7 +21,7 @@ class LLM:
Return an instance depending on the settings. Return an instance depending on the settings.
Settings used: 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 - `LLM_URL`: url of the backend
""" """
if name is None: if name is None:
@@ -44,10 +45,16 @@ class LLM:
async def _warmup(self, logger: reflector_logger): async def _warmup(self, logger: reflector_logger):
pass 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)) logger.info("LLM generate", prompt=repr(prompt))
try: try:
result = await retry(self._generate)(prompt=prompt, **kwargs) result = await retry(self._generate)(prompt=prompt, schema=schema, **kwargs)
except Exception: except Exception:
logger.exception("Failed to call llm after retrying") logger.exception("Failed to call llm after retrying")
raise raise
@@ -59,7 +66,7 @@ class LLM:
return result return result
async def _generate(self, prompt: str, **kwargs) -> str: async def _generate(self, prompt: str, schema: dict | None, **kwargs) -> str:
raise NotImplementedError raise NotImplementedError
def _parse_json(self, result: str) -> dict: def _parse_json(self, result: str) -> dict:

View File

@@ -1,7 +1,7 @@
import httpx
from reflector.llm.base import LLM from reflector.llm.base import LLM
from reflector.settings import settings from reflector.settings import settings
from reflector.utils.retry import retry from reflector.utils.retry import retry
import httpx
class BananaLLM(LLM): class BananaLLM(LLM):
@@ -13,18 +13,22 @@ class BananaLLM(LLM):
"X-Banana-Model-Key": settings.LLM_BANANA_MODEL_KEY, "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: async with httpx.AsyncClient() as client:
response = await retry(client.post)( response = await retry(client.post)(
settings.LLM_URL, settings.LLM_URL,
headers=self.headers, headers=self.headers,
json={"prompt": prompt}, json=json_payload,
timeout=self.timeout, timeout=self.timeout,
retry_timeout=300, # as per their sdk retry_timeout=300, # as per their sdk
) )
response.raise_for_status() response.raise_for_status()
text = response.json()["text"] text = response.json()["text"]
text = text[len(prompt) :] # remove prompt if not schema:
text = text[len(prompt) :]
return text return text

View File

@@ -1,7 +1,7 @@
import httpx
from reflector.llm.base import LLM from reflector.llm.base import LLM
from reflector.settings import settings from reflector.settings import settings
from reflector.utils.retry import retry from reflector.utils.retry import retry
import httpx
class ModalLLM(LLM): class ModalLLM(LLM):
@@ -23,18 +23,22 @@ class ModalLLM(LLM):
) )
response.raise_for_status() 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: async with httpx.AsyncClient() as client:
response = await retry(client.post)( response = await retry(client.post)(
self.llm_url, self.llm_url,
headers=self.headers, headers=self.headers,
json={"prompt": prompt}, json=json_payload,
timeout=self.timeout, timeout=self.timeout,
retry_timeout=60 * 5, retry_timeout=60 * 5,
) )
response.raise_for_status() response.raise_for_status()
text = response.json()["text"] text = response.json()["text"]
text = text[len(prompt) :] # remove prompt if not schema:
text = text[len(prompt) :]
return text return text
@@ -48,6 +52,14 @@ if __name__ == "__main__":
result = await llm.generate("Hello, my name is", logger=logger) result = await llm.generate("Hello, my name is", logger=logger)
print(result) 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 import asyncio
asyncio.run(main()) asyncio.run(main())

View File

@@ -1,18 +1,21 @@
import httpx
from reflector.llm.base import LLM from reflector.llm.base import LLM
from reflector.settings import settings from reflector.settings import settings
import httpx
class OobagoodaLLM(LLM): class OobaboogaLLM(LLM):
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: async with httpx.AsyncClient() as client:
response = await client.post( response = await client.post(
settings.LLM_URL, settings.LLM_URL,
headers={"Content-Type": "application/json"}, headers={"Content-Type": "application/json"},
json={"prompt": prompt}, json=json_payload,
) )
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
LLM.register("oobagooda", OobagoodaLLM) LLM.register("oobabooga", OobaboogaLLM)

View File

@@ -1,7 +1,7 @@
import httpx
from reflector.llm.base import LLM from reflector.llm.base import LLM
from reflector.logger import logger from reflector.logger import logger
from reflector.settings import settings from reflector.settings import settings
import httpx
class OpenAILLM(LLM): class OpenAILLM(LLM):
@@ -15,7 +15,7 @@ class OpenAILLM(LLM):
self.max_tokens = settings.LLM_MAX_TOKENS self.max_tokens = settings.LLM_MAX_TOKENS
logger.info(f"LLM use openai backend at {self.openai_url}") 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 = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": f"Bearer {self.openai_key}", "Authorization": f"Bearer {self.openai_key}",

View File

@@ -32,7 +32,7 @@ class AudioFileWriterProcessor(Processor):
async def _flush(self): async def _flush(self):
if self.out_container: 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.mux(packet)
self.out_container.close() self.out_container.close()
self.out_container = None self.out_container = None

View File

@@ -5,19 +5,22 @@ API will be a POST request to TRANSCRIPT_URL:
```form ```form
"timestamp": 123.456 "timestamp": 123.456
"language": "en" "source_language": "en"
"target_language": "en"
"file": <audio file> "file": <audio file>
``` ```
""" """
from time import monotonic
import httpx
from reflector.processors.audio_transcript import AudioTranscriptProcessor from reflector.processors.audio_transcript import AudioTranscriptProcessor
from reflector.processors.audio_transcript_auto import AudioTranscriptAutoProcessor from reflector.processors.audio_transcript_auto import AudioTranscriptAutoProcessor
from reflector.processors.types import AudioFile, Transcript, Word from reflector.processors.types import AudioFile, Transcript, TranslationLanguages, Word
from reflector.settings import settings from reflector.settings import settings
from reflector.utils.retry import retry from reflector.utils.retry import retry
from time import monotonic
import httpx
class AudioTranscriptModalProcessor(AudioTranscriptProcessor): class AudioTranscriptModalProcessor(AudioTranscriptProcessor):
@@ -26,9 +29,7 @@ class AudioTranscriptModalProcessor(AudioTranscriptProcessor):
self.transcript_url = settings.TRANSCRIPT_URL + "/transcribe" self.transcript_url = settings.TRANSCRIPT_URL + "/transcribe"
self.warmup_url = settings.TRANSCRIPT_URL + "/warmup" self.warmup_url = settings.TRANSCRIPT_URL + "/warmup"
self.timeout = settings.TRANSCRIPT_TIMEOUT self.timeout = settings.TRANSCRIPT_TIMEOUT
self.headers = { self.headers = {"Authorization": f"Bearer {modal_api_key}"}
"Authorization": f"Bearer {modal_api_key}",
}
async def _warmup(self): async def _warmup(self):
try: try:
@@ -52,11 +53,28 @@ class AudioTranscriptModalProcessor(AudioTranscriptProcessor):
files = { files = {
"file": (data.name, data.fd), "file": (data.name, data.fd),
} }
# TODO: Get the source / target language from the UI preferences dynamically
# Update code here once this is possible.
# i.e) extract from context/session objects
source_language = "en"
target_language = "en"
languages = TranslationLanguages()
# Only way to set the target should be the UI element like dropdown.
# Hence, this assert should never fail.
assert languages.is_supported(target_language)
json_payload = {
"source_language": source_language,
"target_language": target_language,
}
response = await retry(client.post)( response = await retry(client.post)(
self.transcript_url, self.transcript_url,
files=files, files=files,
timeout=self.timeout, timeout=self.timeout,
headers=self.headers, headers=self.headers,
json=json_payload,
) )
self.logger.debug( self.logger.debug(
@@ -64,8 +82,14 @@ class AudioTranscriptModalProcessor(AudioTranscriptProcessor):
) )
response.raise_for_status() response.raise_for_status()
result = response.json() result = response.json()
# Sanity check for translation status in the result
if target_language in result["text"]:
text = result["text"][target_language]
else:
text = result["text"][source_language]
transcript = Transcript( transcript = Transcript(
text=result["text"], text=text,
words=[ words=[
Word( Word(
text=word["text"], text=word["text"],

View File

@@ -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.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): class TranscriptTopicDetectorProcessor(Processor):
@@ -15,9 +15,11 @@ class TranscriptTopicDetectorProcessor(Processor):
PROMPT = """ PROMPT = """
### Human: ### Human:
Create a JSON object as response.The JSON object must have 2 fields: 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 i) title and ii) summary.
for the given text. For the summary field, summarize the given text
in three sentences. 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} {input_text}
@@ -30,6 +32,13 @@ class TranscriptTopicDetectorProcessor(Processor):
self.transcript = None self.transcript = None
self.min_transcript_length = min_transcript_length self.min_transcript_length = min_transcript_length
self.llm = LLM.get_instance() self.llm = LLM.get_instance()
self.topic_detector_schema = {
"type": "object",
"properties": {
"title": {"type": "string"},
"summary": {"type": "string"},
},
}
async def _warmup(self): async def _warmup(self):
await self.llm.warmup(logger=self.logger) await self.llm.warmup(logger=self.logger)
@@ -52,7 +61,9 @@ class TranscriptTopicDetectorProcessor(Processor):
text = self.transcript.text text = self.transcript.text
self.logger.info(f"Topic detector got {len(text)} length transcript") self.logger.info(f"Topic detector got {len(text)} length transcript")
prompt = self.PROMPT.format(input_text=text) 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( summary = TitleSummary(
title=result["title"], title=result["title"],
summary=result["summary"], summary=result["summary"],

View File

@@ -1,7 +1,8 @@
from pydantic import BaseModel, PrivateAttr
from pathlib import Path
import tempfile
import io import io
import tempfile
from pathlib import Path
from pydantic import BaseModel, PrivateAttr
class AudioFile(BaseModel): class AudioFile(BaseModel):
@@ -104,3 +105,117 @@ class TitleSummary(BaseModel):
class FinalSummary(BaseModel): class FinalSummary(BaseModel):
summary: str summary: str
duration: float duration: float
class TranslationLanguages(BaseModel):
language_to_id_mapping: dict = {
"Afrikaans": "af",
"Albanian": "sq",
"Amharic": "am",
"Arabic": "ar",
"Armenian": "hy",
"Asturian": "ast",
"Azerbaijani": "az",
"Bashkir": "ba",
"Belarusian": "be",
"Bengali": "bn",
"Bosnian": "bs",
"Breton": "br",
"Bulgarian": "bg",
"Burmese": "my",
"Catalan; Valencian": "ca",
"Cebuano": "ceb",
"Central Khmer": "km",
"Chinese": "zh",
"Croatian": "hr",
"Czech": "cs",
"Danish": "da",
"Dutch; Flemish": "nl",
"English": "en",
"Estonian": "et",
"Finnish": "fi",
"French": "fr",
"Fulah": "ff",
"Gaelic; Scottish Gaelic": "gd",
"Galician": "gl",
"Ganda": "lg",
"Georgian": "ka",
"German": "de",
"Greeek": "el",
"Gujarati": "gu",
"Haitian; Haitian Creole": "ht",
"Hausa": "ha",
"Hebrew": "he",
"Hindi": "hi",
"Hungarian": "hu",
"Icelandic": "is",
"Igbo": "ig",
"Iloko": "ilo",
"Indonesian": "id",
"Irish": "ga",
"Italian": "it",
"Japanese": "ja",
"Javanese": "jv",
"Kannada": "kn",
"Kazakh": "kk",
"Korean": "ko",
"Lao": "lo",
"Latvian": "lv",
"Lingala": "ln",
"Lithuanian": "lt",
"Luxembourgish; Letzeburgesch": "lb",
"Macedonian": "mk",
"Malagasy": "mg",
"Malay": "ms",
"Malayalam": "ml",
"Marathi": "mr",
"Mongolian": "mn",
"Nepali": "ne",
"Northern Sotho": "ns",
"Norwegian": "no",
"Occitan": "oc",
"Oriya": "or",
"Panjabi; Punjabi": "pa",
"Persian": "fa",
"Polish": "pl",
"Portuguese": "pt",
"Pushto; Pashto": "ps",
"Romanian; Moldavian; Moldovan": "ro",
"Russian": "ru",
"Serbian": "sr",
"Sindhi": "sd",
"Sinhala; Sinhalese": "si",
"Slovak": "sk",
"Slovenian": "sl",
"Somali": "so",
"Spanish": "es",
"Sundanese": "su",
"Swahili": "sw",
"Swati": "ss",
"Swedish": "sv",
"Tagalog": "tl",
"Tamil": "ta",
"Thai": "th",
"Tswana": "tn",
"Turkish": "tr",
"Ukrainian": "uk",
"Urdu": "ur",
"Uzbek": "uz",
"Vietnamese": "vi",
"Welsh": "cy",
"Western Frisian": "fy",
"Wolof": "wo",
"Xhosa": "xh",
"Yiddish": "yi",
"Yoruba": "yo",
"Zulu": "zu",
}
@property
def supported_languages(self):
return self.language_to_id_mapping.values()
def is_supported(self, lang_id: str) -> bool:
if lang_id in self.supported_languages:
return True
return False

View File

@@ -55,8 +55,8 @@ class Settings(BaseSettings):
TRANSCRIPT_STORAGE_AWS_SECRET_ACCESS_KEY: str | None = None TRANSCRIPT_STORAGE_AWS_SECRET_ACCESS_KEY: str | None = None
# LLM # LLM
# available backend: openai, banana, modal, oobagooda # available backend: openai, banana, modal, oobabooga
LLM_BACKEND: str = "oobagooda" LLM_BACKEND: str = "oobabooga"
# LLM common configuration # LLM common configuration
LLM_URL: str | None = None LLM_URL: str | None = None
@@ -79,5 +79,17 @@ class Settings(BaseSettings):
# Sentry # Sentry
SENTRY_DSN: str | None = None 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() settings = Settings()

View File

@@ -0,0 +1,72 @@
import os
from typing import BinaryIO
from fastapi import HTTPException, Request, status
from fastapi.responses import StreamingResponse
def send_bytes_range_requests(
file_obj: BinaryIO, start: int, end: int, chunk_size: int = 10_000
):
"""Send a file in chunks using Range Requests specification RFC7233
`start` and `end` parameters are inclusive due to specification
"""
with file_obj as f:
f.seek(start)
while (pos := f.tell()) <= end:
read_size = min(chunk_size, end + 1 - pos)
yield f.read(read_size)
def _get_range_header(range_header: str, file_size: int) -> tuple[int, int]:
def _invalid_range():
return HTTPException(
status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE,
detail=f"Invalid request range (Range:{range_header!r})",
)
try:
h = range_header.replace("bytes=", "").split("-")
start = int(h[0]) if h[0] != "" else 0
end = int(h[1]) if h[1] != "" else file_size - 1
except ValueError:
raise _invalid_range()
if start > end or start < 0 or end > file_size - 1:
raise _invalid_range()
return start, end
def range_requests_response(request: Request, file_path: str, content_type: str):
"""Returns StreamingResponse using Range Requests of a given file"""
file_size = os.stat(file_path).st_size
range_header = request.headers.get("range")
headers = {
"content-type": content_type,
"accept-ranges": "bytes",
"content-encoding": "identity",
"content-length": str(file_size),
"access-control-expose-headers": (
"content-type, accept-ranges, content-length, "
"content-range, content-encoding"
),
}
start = 0
end = file_size - 1
status_code = status.HTTP_200_OK
if range_header is not None:
start, end = _get_range_header(range_header, file_size)
size = end - start + 1
headers["content-length"] = str(size)
headers["content-range"] = f"bytes {start}-{end}/{file_size}"
status_code = status.HTTP_206_PARTIAL_CONTENT
return StreamingResponse(
send_bytes_range_requests(open(file_path, mode="rb"), start, end),
headers=headers,
status_code=status_code,
)

View File

@@ -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 ( from fastapi import (
APIRouter, APIRouter,
Depends,
HTTPException, HTTPException,
Request, Request,
WebSocket, WebSocket,
WebSocketDisconnect, 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 fastapi_pagination import Page, paginate
from reflector.logger import logger from pydantic import BaseModel, Field
from reflector.db import database, transcripts from reflector.db import database, transcripts
from reflector.logger import logger
from reflector.settings import settings from reflector.settings import settings
from .rtc_offer import rtc_offer_base, RtcOffer, PipelineEvent from starlette.concurrency import run_in_threadpool
from typing import Optional
from pathlib import Path
from tempfile import NamedTemporaryFile
import av
from ._range_requests_response import range_requests_response
from .rtc_offer import PipelineEvent, RtcOffer, rtc_offer_base
router = APIRouter() router = APIRouter()
@@ -60,6 +63,7 @@ class TranscriptEvent(BaseModel):
class Transcript(BaseModel): class Transcript(BaseModel):
id: str = Field(default_factory=generate_uuid4) id: str = Field(default_factory=generate_uuid4)
user_id: str | None = None
name: str = Field(default_factory=generate_transcript_name) name: str = Field(default_factory=generate_transcript_name)
status: str = "idle" status: str = "idle"
locked: bool = False locked: bool = False
@@ -108,7 +112,9 @@ class Transcript(BaseModel):
out.close() out.close()
# move temporary file to final location # move temporary file to final location
Path(tmp.name).rename(fn) import shutil
shutil.move(tmp.name, fn.as_posix())
def unlink(self): def unlink(self):
self.data_path.unlink(missing_ok=True) self.data_path.unlink(missing_ok=True)
@@ -127,20 +133,22 @@ class Transcript(BaseModel):
class TranscriptController: class TranscriptController:
async def get_all(self) -> list[Transcript]: async def get_all(self, user_id: str | None = None) -> list[Transcript]:
query = transcripts.select() query = transcripts.select().where(transcripts.c.user_id == user_id)
results = await database.fetch_all(query) results = await database.fetch_all(query)
return results 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) 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) result = await database.fetch_one(query)
if not result: if not result:
return None return None
return Transcript(**result) return Transcript(**result)
async def add(self, name: str): async def add(self, name: str, user_id: str | None = None):
transcript = Transcript(name=name) transcript = Transcript(name=name, user_id=user_id)
query = transcripts.insert().values(**transcript.model_dump()) query = transcripts.insert().values(**transcript.model_dump())
await database.execute(query) await database.execute(query)
return transcript return transcript
@@ -155,10 +163,14 @@ class TranscriptController:
for key, value in values.items(): for key, value in values.items():
setattr(transcript, key, value) setattr(transcript, key, value)
async def remove_by_id(self, transcript_id: str) -> None: async def remove_by_id(
transcript = await self.get_by_id(transcript_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: if not transcript:
return return
if user_id is not None and transcript.user_id != user_id:
return
transcript.unlink() transcript.unlink()
query = transcripts.delete().where(transcripts.c.id == transcript_id) query = transcripts.delete().where(transcripts.c.id == transcript_id)
await database.execute(query) await database.execute(query)
@@ -178,6 +190,7 @@ class GetTranscript(BaseModel):
status: str status: str
locked: bool locked: bool
duration: int duration: int
summary: str | None
created_at: datetime created_at: datetime
@@ -188,6 +201,7 @@ class CreateTranscript(BaseModel):
class UpdateTranscript(BaseModel): class UpdateTranscript(BaseModel):
name: Optional[str] = Field(None) name: Optional[str] = Field(None)
locked: Optional[bool] = Field(None) locked: Optional[bool] = Field(None)
summary: Optional[str] = Field(None)
class TranscriptEntryCreate(BaseModel): class TranscriptEntryCreate(BaseModel):
@@ -199,13 +213,23 @@ class DeletionStatus(BaseModel):
@router.get("/transcripts", response_model=Page[GetTranscript]) @router.get("/transcripts", response_model=Page[GetTranscript])
async def transcripts_list(): async def transcripts_list(
return paginate(await transcripts_controller.get_all()) 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) @router.post("/transcripts", response_model=GetTranscript)
async def transcripts_create(info: CreateTranscript): async def transcripts_create(
return await transcripts_controller.add(info.name) 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 +238,25 @@ async def transcripts_create(info: CreateTranscript):
@router.get("/transcripts/{transcript_id}", response_model=GetTranscript) @router.get("/transcripts/{transcript_id}", response_model=GetTranscript)
async def transcript_get(transcript_id: str): async def transcript_get(
transcript = await transcripts_controller.get_by_id(transcript_id) 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: if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found") raise HTTPException(status_code=404, detail="Transcript not found")
return transcript return transcript
@router.patch("/transcripts/{transcript_id}", response_model=GetTranscript) @router.patch("/transcripts/{transcript_id}", response_model=GetTranscript)
async def transcript_update(transcript_id: str, info: UpdateTranscript): async def transcript_update(
transcript = await transcripts_controller.get_by_id(transcript_id) 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: if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found") raise HTTPException(status_code=404, detail="Transcript not found")
values = {} values = {}
@@ -231,34 +264,61 @@ async def transcript_update(transcript_id: str, info: UpdateTranscript):
values["name"] = info.name values["name"] = info.name
if info.locked is not None: if info.locked is not None:
values["locked"] = info.locked values["locked"] = info.locked
if info.summary is not None:
values["summary"] = info.summary
# also find FINAL_SUMMARY event and patch it
for te in transcript.events:
if te["event"] == PipelineEvent.FINAL_SUMMARY:
te["summary"] = info.summary
break
values["events"] = transcript.events
await transcripts_controller.update(transcript, values) await transcripts_controller.update(transcript, values)
return transcript return transcript
@router.delete("/transcripts/{transcript_id}", response_model=DeletionStatus) @router.delete("/transcripts/{transcript_id}", response_model=DeletionStatus)
async def transcript_delete(transcript_id: str): async def transcript_delete(
transcript = await transcripts_controller.get_by_id(transcript_id) 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: if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found") 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") return DeletionStatus(status="ok")
@router.get("/transcripts/{transcript_id}/audio") @router.get("/transcripts/{transcript_id}/audio")
async def transcript_get_audio(transcript_id: str): async def transcript_get_audio(
transcript = await transcripts_controller.get_by_id(transcript_id) request: Request,
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: if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found") raise HTTPException(status_code=404, detail="Transcript not found")
if not transcript.audio_filename.exists(): if not transcript.audio_filename.exists():
raise HTTPException(status_code=404, detail="Audio not found") raise HTTPException(status_code=404, detail="Audio not found")
return FileResponse(transcript.audio_filename, media_type="audio/wav") return range_requests_response(
request,
transcript.audio_filename,
content_type="audio/wav",
)
@router.get("/transcripts/{transcript_id}/audio/mp3") @router.get("/transcripts/{transcript_id}/audio/mp3")
async def transcript_get_audio_mp3(transcript_id: str): async def transcript_get_audio_mp3(
transcript = await transcripts_controller.get_by_id(transcript_id) request: Request,
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: if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found") raise HTTPException(status_code=404, detail="Transcript not found")
@@ -267,12 +327,20 @@ async def transcript_get_audio_mp3(transcript_id: str):
await run_in_threadpool(transcript.convert_audio_to_mp3) await run_in_threadpool(transcript.convert_audio_to_mp3)
return FileResponse(transcript.audio_mp3_filename, media_type="audio/mp3") return range_requests_response(
request,
transcript.audio_mp3_filename,
content_type="audio/mp3",
)
@router.get("/transcripts/{transcript_id}/topics", response_model=list[TranscriptTopic]) @router.get("/transcripts/{transcript_id}/topics", response_model=list[TranscriptTopic])
async def transcript_get_topics(transcript_id: str): async def transcript_get_topics(
transcript = await transcripts_controller.get_by_id(transcript_id) 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: if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found") raise HTTPException(status_code=404, detail="Transcript not found")
return transcript.topics return transcript.topics
@@ -319,7 +387,12 @@ ws_manager = WebsocketManager()
@router.websocket("/transcripts/{transcript_id}/events") @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) transcript = await transcripts_controller.get_by_id(transcript_id)
if not transcript: if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found") raise HTTPException(status_code=404, detail="Transcript not found")
@@ -420,9 +493,13 @@ async def handle_rtc_event(event: PipelineEvent, args, data):
@router.post("/transcripts/{transcript_id}/record/webrtc") @router.post("/transcripts/{transcript_id}/record/webrtc")
async def transcript_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: if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found") raise HTTPException(status_code=404, detail="Transcript not found")

View 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

Binary file not shown.

16
server/tests/conftest.py Normal file
View 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

View File

@@ -15,7 +15,7 @@ async def test_basic_process(event_loop):
settings.TRANSCRIPT_BACKEND = "whisper" settings.TRANSCRIPT_BACKEND = "whisper"
class LLMTest(LLM): class LLMTest(LLM):
async def _generate(self, prompt: str, **kwargs) -> str: async def _generate(self, prompt: str, schema: dict | None, **kwargs) -> str:
return { return {
"title": "TITLE", "title": "TITLE",
"summary": "SUMMARY", "summary": "SUMMARY",

View File

@@ -1,10 +1,11 @@
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from reflector.app import app
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_transcript_create(): async def test_transcript_create():
from reflector.app import app
async with AsyncClient(app=app, base_url="http://test/v1") as ac: async with AsyncClient(app=app, base_url="http://test/v1") as ac:
response = await ac.post("/transcripts", json={"name": "test"}) response = await ac.post("/transcripts", json={"name": "test"})
assert response.status_code == 200 assert response.status_code == 200
@@ -21,6 +22,8 @@ async def test_transcript_create():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_transcript_get_update_name(): async def test_transcript_get_update_name():
from reflector.app import app
async with AsyncClient(app=app, base_url="http://test/v1") as ac: async with AsyncClient(app=app, base_url="http://test/v1") as ac:
response = await ac.post("/transcripts", json={"name": "test"}) response = await ac.post("/transcripts", json={"name": "test"})
assert response.status_code == 200 assert response.status_code == 200
@@ -42,9 +45,93 @@ async def test_transcript_get_update_name():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_transcripts_list(): async def test_transcript_get_update_locked():
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
assert response.json()["locked"] is False
tid = response.json()["id"]
response = await ac.get(f"/transcripts/{tid}")
assert response.status_code == 200
assert response.json()["locked"] is False
response = await ac.patch(f"/transcripts/{tid}", json={"locked": True})
assert response.status_code == 200
assert response.json()["locked"] is True
response = await ac.get(f"/transcripts/{tid}")
assert response.status_code == 200
assert response.json()["locked"] is True
@pytest.mark.asyncio
async def test_transcript_get_update_summary():
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
assert response.json()["summary"] is None
tid = response.json()["id"]
response = await ac.get(f"/transcripts/{tid}")
assert response.status_code == 200
assert response.json()["summary"] is None
response = await ac.patch(f"/transcripts/{tid}", json={"summary": "test"})
assert response.status_code == 200
assert response.json()["summary"] == "test"
response = await ac.get(f"/transcripts/{tid}")
assert response.status_code == 200
assert response.json()["summary"] == "test"
@pytest.mark.asyncio
async def test_transcripts_list_anonymous():
# XXX this test is a bit fragile, as it depends on the storage which # XXX this test is a bit fragile, as it depends on the storage which
# is shared between tests # 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: async with AsyncClient(app=app, base_url="http://test/v1") as ac:
response = await ac.post("/transcripts", json={"name": "testxx1"}) response = await ac.post("/transcripts", json={"name": "testxx1"})
assert response.status_code == 200 assert response.status_code == 200
@@ -64,6 +151,8 @@ async def test_transcripts_list():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_transcript_delete(): async def test_transcript_delete():
from reflector.app import app
async with AsyncClient(app=app, base_url="http://test/v1") as ac: async with AsyncClient(app=app, base_url="http://test/v1") as ac:
response = await ac.post("/transcripts", json={"name": "testdel1"}) response = await ac.post("/transcripts", json={"name": "testdel1"})
assert response.status_code == 200 assert response.status_code == 200

View File

@@ -0,0 +1,95 @@
import pytest
import shutil
from httpx import AsyncClient
from pathlib import Path
@pytest.fixture
async def fake_transcript(tmpdir):
from reflector.settings import settings
from reflector.app import app
from reflector.views.transcripts import transcripts_controller
settings.DATA_DIR = Path(tmpdir)
# create a transcript
ac = AsyncClient(app=app, base_url="http://test/v1")
response = await ac.post("/transcripts", json={"name": "Test audio download"})
assert response.status_code == 200
tid = response.json()["id"]
transcript = await transcripts_controller.get_by_id(tid)
assert transcript is not None
await transcripts_controller.update(transcript, {"status": "finished"})
# manually copy a file at the expected location
audio_filename = transcript.audio_filename
path = Path(__file__).parent / "records" / "test_mathieu_hello.wav"
audio_filename.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(path, audio_filename)
yield transcript
@pytest.mark.asyncio
@pytest.mark.parametrize(
"url_suffix,content_type",
[
["", "audio/wav"],
["/mp3", "audio/mp3"],
],
)
async def test_transcript_audio_download(fake_transcript, url_suffix, content_type):
from reflector.app import app
ac = AsyncClient(app=app, base_url="http://test/v1")
response = await ac.get(f"/transcripts/{fake_transcript.id}/audio{url_suffix}")
assert response.status_code == 200
assert response.headers["content-type"] == content_type
@pytest.mark.asyncio
@pytest.mark.parametrize(
"url_suffix,content_type",
[
["", "audio/wav"],
["/mp3", "audio/mp3"],
],
)
async def test_transcript_audio_download_range(
fake_transcript, url_suffix, content_type
):
from reflector.app import app
ac = AsyncClient(app=app, base_url="http://test/v1")
response = await ac.get(
f"/transcripts/{fake_transcript.id}/audio{url_suffix}",
headers={"range": "bytes=0-100"},
)
assert response.status_code == 206
assert response.headers["content-type"] == content_type
assert response.headers["content-range"].startswith("bytes 0-100/")
assert response.headers["content-length"] == "101"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"url_suffix,content_type",
[
["", "audio/wav"],
["/mp3", "audio/mp3"],
],
)
async def test_transcript_audio_download_range_with_seek(
fake_transcript, url_suffix, content_type
):
from reflector.app import app
ac = AsyncClient(app=app, base_url="http://test/v1")
response = await ac.get(
f"/transcripts/{fake_transcript.id}/audio{url_suffix}",
headers={"range": "bytes=100-"},
)
assert response.status_code == 206
assert response.headers["content-type"] == content_type
assert response.headers["content-range"].startswith("bytes 100-")

View File

@@ -3,17 +3,16 @@
# FIXME test websocket connection after RTC is finished still send the full events # FIXME test websocket connection after RTC is finished still send the full events
# FIXME try with locked session, RTC should not work # 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 asyncio
import json
import threading
from pathlib import Path from pathlib import Path
from unittest.mock import patch
import pytest
from httpx import AsyncClient
from httpx_ws import aconnect_ws from httpx_ws import aconnect_ws
from uvicorn import Config, Server
class ThreadedUvicorn: class ThreadedUvicorn:
@@ -61,7 +60,7 @@ async def dummy_llm():
from reflector.llm.base import LLM from reflector.llm.base import LLM
class TestLLM(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"}) return json.dumps({"title": "LLM TITLE", "summary": "LLM SUMMARY"})
with patch("reflector.llm.base.LLM.get_instance") as mock_llm: 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 # to be able to connect with aiortc
from reflector.settings import settings from reflector.settings import settings
from reflector.app import app
settings.DATA_DIR = Path(tmpdir) settings.DATA_DIR = Path(tmpdir)

View File

@@ -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: 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. You may need to run `yarn global add @openapitools/openapi-generator-cli` first. You also need a Java runtime installed on your machine.

View 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>
);
}

View 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>
);
}

View File

@@ -9,6 +9,7 @@ models/PageGetTranscript.ts
models/RtcOffer.ts models/RtcOffer.ts
models/TranscriptTopic.ts models/TranscriptTopic.ts
models/UpdateTranscript.ts models/UpdateTranscript.ts
models/UserInfo.ts
models/ValidationError.ts models/ValidationError.ts
models/index.ts models/index.ts
runtime.ts runtime.ts

View File

@@ -55,6 +55,10 @@ export interface V1TranscriptGetAudioRequest {
transcriptId: any; transcriptId: any;
} }
export interface V1TranscriptGetAudioMp3Request {
transcriptId: any;
}
export interface V1TranscriptGetTopicsRequest { export interface V1TranscriptGetTopicsRequest {
transcriptId: any; transcriptId: any;
} }
@@ -159,6 +163,14 @@ export class DefaultApi extends runtime.BaseAPI {
const headerParameters: runtime.HTTPHeaders = {}; const headerParameters: runtime.HTTPHeaders = {};
if (this.configuration && this.configuration.accessToken) {
// oauth required
headerParameters["Authorization"] = await this.configuration.accessToken(
"OAuth2AuthorizationCodeBearer",
[],
);
}
const response = await this.request( const response = await this.request(
{ {
path: `/v1/transcripts/{transcript_id}`.replace( path: `/v1/transcripts/{transcript_id}`.replace(
@@ -212,6 +224,14 @@ export class DefaultApi extends runtime.BaseAPI {
const headerParameters: runtime.HTTPHeaders = {}; const headerParameters: runtime.HTTPHeaders = {};
if (this.configuration && this.configuration.accessToken) {
// oauth required
headerParameters["Authorization"] = await this.configuration.accessToken(
"OAuth2AuthorizationCodeBearer",
[],
);
}
const response = await this.request( const response = await this.request(
{ {
path: `/v1/transcripts/{transcript_id}`.replace( path: `/v1/transcripts/{transcript_id}`.replace(
@@ -299,6 +319,61 @@ export class DefaultApi extends runtime.BaseAPI {
return await response.value(); 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 * Transcript Get Topics
*/ */
@@ -510,6 +585,14 @@ export class DefaultApi extends runtime.BaseAPI {
headerParameters["Content-Type"] = "application/json"; 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( const response = await this.request(
{ {
path: `/v1/transcripts/{transcript_id}`.replace( path: `/v1/transcripts/{transcript_id}`.replace(
@@ -566,6 +649,14 @@ export class DefaultApi extends runtime.BaseAPI {
headerParameters["Content-Type"] = "application/json"; 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( const response = await this.request(
{ {
path: `/v1/transcripts`, path: `/v1/transcripts`,
@@ -615,6 +706,14 @@ export class DefaultApi extends runtime.BaseAPI {
const headerParameters: runtime.HTTPHeaders = {}; const headerParameters: runtime.HTTPHeaders = {};
if (this.configuration && this.configuration.accessToken) {
// oauth required
headerParameters["Authorization"] = await this.configuration.accessToken(
"OAuth2AuthorizationCodeBearer",
[],
);
}
const response = await this.request( const response = await this.request(
{ {
path: `/v1/transcripts`, path: `/v1/transcripts`,
@@ -643,4 +742,49 @@ export class DefaultApi extends runtime.BaseAPI {
); );
return await response.value(); 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();
}
} }

View 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,
};
}

View File

@@ -8,4 +8,5 @@ export * from "./PageGetTranscript";
export * from "./RtcOffer"; export * from "./RtcOffer";
export * from "./TranscriptTopic"; export * from "./TranscriptTopic";
export * from "./UpdateTranscript"; export * from "./UpdateTranscript";
export * from "./UserInfo";
export * from "./ValidationError"; export * from "./ValidationError";

View File

@@ -1,11 +1,12 @@
import "./styles/globals.scss"; import "./styles/globals.scss";
import { Roboto } from "next/font/google"; import { Roboto } from "next/font/google";
import { Metadata } from "next";
import Head from "next/head"; import FiefWrapper from "./(auth)/fiefWrapper";
import UserInfo from "./(auth)/userInfo";
const roboto = Roboto({ subsets: ["latin"], weight: "400" }); const roboto = Roboto({ subsets: ["latin"], weight: "400" });
export const metadata = { export const metadata: Metadata = {
title: { title: {
template: "%s Reflector", template: "%s Reflector",
default: "Reflector - AI-Powered Meeting Transcriptions by Monadical", default: "Reflector - AI-Powered Meeting Transcriptions by Monadical",
@@ -52,11 +53,22 @@ export const metadata = {
export default function RootLayout({ children }) { export default function RootLayout({ children }) {
return ( return (
<html lang="en"> <html lang="en">
<Head>
<title>Test</title>
</Head>
<body className={roboto.className + " flex flex-col min-h-screen"}> <body className={roboto.className + " flex flex-col min-h-screen"}>
<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} {children}
</div>
</div>
</FiefWrapper>
</body> </body>
</html> </html>
); );

50
www/app/lib/fief.ts Normal file
View 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
View 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;
}

View File

@@ -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; return Math.floor(Math.random() * (max - min + 1)) + min;
} }
export function SeededRand(seed) { export function SeededRand(seed: number): number {
seed ^= seed << 13; seed ^= seed << 13;
seed ^= seed >> 17; seed ^= seed >> 17;
seed ^= seed << 5; seed ^= seed << 5;
return seed / 2 ** 32; return seed / 2 ** 32;
} }
export function Mulberry32(seed) { export function Mulberry32(seed: number) {
return function () { return function () {
var t = (seed += 0x6d2b79f5); var t = (seed += 0x6d2b79f5);
t = Math.imul(t ^ (t >>> 15), t | 1); t = Math.imul(t ^ (t >>> 15), t | 1);

View File

@@ -1,4 +1,4 @@
export const formatTime = (seconds) => { export const formatTime = (seconds: number): string => {
let hours = Math.floor(seconds / 3600); let hours = Math.floor(seconds / 3600);
let minutes = Math.floor((seconds % 3600) / 60); let minutes = Math.floor((seconds % 3600) / 60);
let secs = Math.floor(seconds % 60); let secs = Math.floor(seconds % 60);

21
www/app/lib/user.ts Normal file
View 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
}
}

View File

@@ -1,4 +1,4 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
export default async function Index({ params }) { export default async function Index() {
redirect("/transcripts/new"); redirect("/transcripts/new");
} }

View File

@@ -3,17 +3,29 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { import {
faChevronRight, faChevronRight,
faChevronDown, faChevronDown,
faLinkSlash,
} from "@fortawesome/free-solid-svg-icons"; } 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;
};
export function Dashboard({ export function Dashboard({
transcriptionText, transcriptionText,
finalSummary, finalSummary,
topics, topics,
disconnected, disconnected,
}) { }: DashboardProps) {
const [openIndex, setOpenIndex] = useState(null); const [openIndex, setOpenIndex] = useState<number | null>(null);
const [autoscrollEnabled, setAutoscrollEnabled] = useState(true); const [autoscrollEnabled, setAutoscrollEnabled] = useState<boolean>(true);
useEffect(() => { useEffect(() => {
if (autoscrollEnabled) scrollToBottom(); if (autoscrollEnabled) scrollToBottom();
@@ -21,7 +33,10 @@ export function Dashboard({
const scrollToBottom = () => { const scrollToBottom = () => {
const topicsDiv = document.getElementById("topics-div"); 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) => { const handleScroll = (e) => {
@@ -34,18 +49,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 ( return (
<> <>
<div className="relative h-[60svh] w-3/4 flex flex-col"> <div className="relative h-[60svh] w-3/4 flex flex-col">
@@ -57,16 +60,12 @@ export function Dashboard({
<div className="w-3/4 font-bold">Topic</div> <div className="w-3/4 font-bold">Topic</div>
</div> </div>
<div <ScrollToBottom
className={`absolute right-5 w-10 h-10 ${ visible={!autoscrollEnabled}
autoscrollEnabled ? "hidden" : "flex" hasFinalSummary={finalSummary ? true : false}
} ${ handleScrollBottom={scrollToBottom}
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}
>
&#11015;
</div>
<div <div
id="topics-div" id="topics-div"
className="py-2 overflow-y-auto" className="py-2 overflow-y-auto"
@@ -99,26 +98,12 @@ export function Dashboard({
)} )}
</div> </div>
{finalSummary && ( {finalSummary.summary && <FinalSummary text={finalSummary.summary} />}
<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>
)}
</div> </div>
{disconnected && ( {disconnected && <DisconnectedIndicator />}
<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>
)}
<footer className="h-[7svh] w-full bg-gray-800 text-white text-center py-4 text-2xl"> <LiveTrancription text={transcriptionText} />
&nbsp;{transcriptionText}&nbsp;
</footer>
</> </>
); );
} }

View 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>
);
}

View 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>
);
}

View 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">
&nbsp;{props.text}&nbsp;
</div>
);
}

View File

@@ -7,10 +7,11 @@ import useTranscript from "../useTranscript";
import { useWebSockets } from "../useWebSockets"; import { useWebSockets } from "../useWebSockets";
import useAudioDevice from "../useAudioDevice"; import useAudioDevice from "../useAudioDevice";
import "../../styles/button.css"; import "../../styles/button.css";
import getApi from "../../lib/getApi";
const App = () => { const App = () => {
const [stream, setStream] = useState(null); const [stream, setStream] = useState<MediaStream | null>(null);
const [disconnected, setDisconnected] = useState(false); const [disconnected, setDisconnected] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
if (process.env.NEXT_PUBLIC_ENV === "development") { if (process.env.NEXT_PUBLIC_ENV === "development") {
@@ -22,8 +23,9 @@ const App = () => {
} }
}, []); }, []);
const api = getApi();
const transcript = useTranscript(); const transcript = useTranscript();
const webRTC = useWebRTC(stream, transcript.response?.id); const webRTC = useWebRTC(stream, transcript.response?.id, api);
const webSockets = useWebSockets(transcript.response?.id); const webSockets = useWebSockets(transcript.response?.id);
const { const {
loading, loading,
@@ -56,7 +58,6 @@ const App = () => {
transcriptionText={webSockets.transcriptText} transcriptionText={webSockets.transcriptText}
finalSummary={webSockets.finalSummary} finalSummary={webSockets.finalSummary}
topics={webSockets.topics} topics={webSockets.topics}
stream={stream}
disconnected={disconnected} disconnected={disconnected}
/> />
</> </>

View File

@@ -8,7 +8,7 @@ import { faDownload } from "@fortawesome/free-solid-svg-icons";
import Dropdown from "react-dropdown"; import Dropdown from "react-dropdown";
import "react-dropdown/style.css"; import "react-dropdown/style.css";
import CustomRecordPlugin from "./CustomRecordPlugin"; import CustomRecordPlugin from "../lib/CustomRecordPlugin";
import { formatTime } from "../lib/time"; import { formatTime } from "../lib/time";
const AudioInputsDropdown = (props) => { const AudioInputsDropdown = (props) => {

View 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;
}}
>
&#11015;
</div>
);
}

View File

@@ -1,20 +1,26 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { DefaultApi } from "../api/apis/DefaultApi"; import { DefaultApi, V1TranscriptsCreateRequest } from "../api/apis/DefaultApi";
import { Configuration } from "../api/runtime"; import { Configuration } from "../api/runtime";
import { GetTranscript } from "../api";
import getApi from "../lib/getApi";
const useTranscript = () => { type UseTranscript = {
const [response, setResponse] = useState(null); response: GetTranscript | null;
const [loading, setLoading] = useState(false); loading: boolean;
const [error, setError] = useState(null); error: string | null;
createTranscript: () => void;
};
const apiConfiguration = new Configuration({ const useTranscript = (): UseTranscript => {
basePath: process.env.NEXT_PUBLIC_API_URL, const [response, setResponse] = useState<GetTranscript | null>(null);
}); const [loading, setLoading] = useState<boolean>(false);
const api = new DefaultApi(apiConfiguration); const [error, setError] = useState<string | null>(null);
const api = getApi();
const createTranscript = () => { const createTranscript = () => {
setLoading(true); setLoading(true);
const requestParameters = { const requestParameters: V1TranscriptsCreateRequest = {
createTranscript: { createTranscript: {
name: "Weekly All-Hands", // Hardcoded for now name: "Weekly All-Hands", // Hardcoded for now
}, },

View File

@@ -1,28 +1,28 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Peer from "simple-peer"; import Peer from "simple-peer";
import { DefaultApi } from "../api/apis/DefaultApi"; import {
DefaultApi,
V1TranscriptRecordWebrtcRequest,
} from "../api/apis/DefaultApi";
import { Configuration } from "../api/runtime"; import { Configuration } from "../api/runtime";
const useWebRTC = (stream, transcriptId) => { const useWebRTC = (
const [data, setData] = useState({ stream: MediaStream | null,
peer: null, transcriptId: string | null,
}); api: DefaultApi,
): Peer => {
const [peer, setPeer] = useState<Peer | null>(null);
useEffect(() => { useEffect(() => {
if (!stream || !transcriptId) { if (!stream || !transcriptId) {
return; return;
} }
const apiConfiguration = new Configuration({ let p: Peer = new Peer({ initiator: true, stream: stream });
basePath: process.env.NEXT_PUBLIC_API_URL,
});
const api = new DefaultApi(apiConfiguration);
let peer = new Peer({ initiator: true, stream: stream }); p.on("signal", (data: any) => {
peer.on("signal", (data) => {
if ("sdp" in data) { if ("sdp" in data) {
const requestParameters = { const requestParameters: V1TranscriptRecordWebrtcRequest = {
transcriptId: transcriptId, transcriptId: transcriptId,
rtcOffer: { rtcOffer: {
sdp: data.sdp, sdp: data.sdp,
@@ -33,7 +33,7 @@ const useWebRTC = (stream, transcriptId) => {
api api
.v1TranscriptRecordWebrtc(requestParameters) .v1TranscriptRecordWebrtc(requestParameters)
.then((answer) => { .then((answer) => {
peer.signal(answer); p.signal(answer);
}) })
.catch((err) => { .catch((err) => {
console.error("WebRTC signaling error:", err); console.error("WebRTC signaling error:", err);
@@ -41,17 +41,17 @@ const useWebRTC = (stream, transcriptId) => {
} }
}); });
peer.on("connect", () => { p.on("connect", () => {
console.log("WebRTC connected"); console.log("WebRTC connected");
setData((prevData) => ({ ...prevData, peer: peer })); setPeer(p);
}); });
return () => { return () => {
peer.destroy(); p.destroy();
}; };
}, [stream, transcriptId]); }, [stream, transcriptId]);
return data; return peer;
}; };
export default useWebRTC; export default useWebRTC;

View File

@@ -1,10 +1,20 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Topic, FinalSummary, Status } from "./webSocketTypes";
export const useWebSockets = (transcriptId) => { type UseWebSockets = {
const [transcriptText, setTranscriptText] = useState(""); transcriptText: string;
const [topics, setTopics] = useState([]); topics: Topic[];
const [finalSummary, setFinalSummary] = useState(""); finalSummary: FinalSummary;
const [status, setStatus] = useState("disconnected"); 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(() => { useEffect(() => {
if (!transcriptId) return; if (!transcriptId) return;
@@ -40,7 +50,7 @@ export const useWebSockets = (transcriptId) => {
break; break;
case "STATUS": case "STATUS":
setStatus(message.data.status); setStatus(message.data);
break; break;
default: default:

View 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
View 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);
}

View File

@@ -5,7 +5,7 @@ const nextConfig = {
module.exports = nextConfig; module.exports = nextConfig;
// Sentry content below // Injected content via Sentry wizard below
const { withSentryConfig } = require("@sentry/nextjs"); const { withSentryConfig } = require("@sentry/nextjs");

View File

@@ -11,10 +11,11 @@
"openapi": "openapi-generator-cli generate -i http://localhost:1250/openapi.json -g typescript-fetch -o app/api && yarn format" "openapi": "openapi-generator-cli generate -i http://localhost:1250/openapi.json -g typescript-fetch -o app/api && yarn format"
}, },
"dependencies": { "dependencies": {
"@fief/fief": "^0.13.5",
"@fortawesome/fontawesome-svg-core": "^6.4.0", "@fortawesome/fontawesome-svg-core": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0", "@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fortawesome/react-fontawesome": "^0.2.0", "@fortawesome/react-fontawesome": "^0.2.0",
"@sentry/nextjs": "^7.61.0", "@sentry/nextjs": "^7.64.0",
"autoprefixer": "10.4.14", "autoprefixer": "10.4.14",
"axios": "^1.4.0", "axios": "^1.4.0",
"fontawesome": "^5.6.3", "fontawesome": "^5.6.3",

View File

@@ -0,0 +1,3 @@
import { fiefAuth } from "../../app/lib/fief";
export default fiefAuth.currentUser();

7
www/pages/forbidden.tsx Normal file
View 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;

View File

@@ -14,6 +14,16 @@
dependencies: dependencies:
regenerator-runtime "^0.14.0" 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": "@fortawesome/fontawesome-common-types@6.4.0":
version "6.4.0" version "6.4.0"
resolved "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.0.tgz" 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" estree-walker "^2.0.2"
picomatch "^2.3.1" picomatch "^2.3.1"
"@sentry-internal/tracing@7.61.0": "@sentry-internal/tracing@7.64.0":
version "7.61.0" version "7.64.0"
resolved "https://registry.yarnpkg.com/@sentry-internal/tracing/-/tracing-7.61.0.tgz#5a0dd4a9a0b41f2e22904430f3fe0216f36ee086" resolved "https://registry.yarnpkg.com/@sentry-internal/tracing/-/tracing-7.64.0.tgz#3e110473b8edf805b799cc91d6ee592830237bb4"
integrity sha512-zTr+MXEG4SxNxif42LIgm2RQn+JRXL2NuGhRaKSD2i4lXKFqHVGlVdoWqY5UfqnnJPokiTWIj9ejR8I5HV8Ogw== integrity sha512-1XE8W6ki7hHyBvX9hfirnGkKDBKNq3bDJyXS86E0bYVDl94nvbRM9BD9DHsCFetqYkVm1yDGEK+6aUVs4CztoQ==
dependencies: dependencies:
"@sentry/core" "7.61.0" "@sentry/core" "7.64.0"
"@sentry/types" "7.61.0" "@sentry/types" "7.64.0"
"@sentry/utils" "7.61.0" "@sentry/utils" "7.64.0"
tslib "^2.4.1 || ^1.9.3" tslib "^2.4.1 || ^1.9.3"
"@sentry/browser@7.61.0": "@sentry/browser@7.64.0":
version "7.61.0" version "7.64.0"
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-7.61.0.tgz#04f4122e444d8b5ffefed97af3cde2bc1c71bb80" resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-7.64.0.tgz#76db08a5d32ffe7c5aa907f258e6c845ce7f10d7"
integrity sha512-IGEkJZRP16Oe5CkXkmhU3QdV5RugW6Vds16yJFFYsgp87NprWtRZgqzldFDYkINStfBHVdctj/Rh/ZrLf8QlkQ== integrity sha512-lB2IWUkZavEDclxfLBp554dY10ZNIEvlDZUWWathW+Ws2wRb6PNLtuPUNu12R7Q7z0xpkOLrM1kRNN0OdldgKA==
dependencies: dependencies:
"@sentry-internal/tracing" "7.61.0" "@sentry-internal/tracing" "7.64.0"
"@sentry/core" "7.61.0" "@sentry/core" "7.64.0"
"@sentry/replay" "7.61.0" "@sentry/replay" "7.64.0"
"@sentry/types" "7.61.0" "@sentry/types" "7.64.0"
"@sentry/utils" "7.61.0" "@sentry/utils" "7.64.0"
tslib "^2.4.1 || ^1.9.3" tslib "^2.4.1 || ^1.9.3"
"@sentry/cli@^1.74.6": "@sentry/cli@^1.74.6":
@@ -286,88 +296,88 @@
proxy-from-env "^1.1.0" proxy-from-env "^1.1.0"
which "^2.0.2" which "^2.0.2"
"@sentry/core@7.61.0": "@sentry/core@7.64.0":
version "7.61.0" version "7.64.0"
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-7.61.0.tgz#0de4f73055bd156c5c0cbac50bb814b272567188" resolved "https://registry.yarnpkg.com/@sentry/core/-/core-7.64.0.tgz#9d61cdc29ba299dedbdcbe01cfadf94bd0b7df48"
integrity sha512-zl0ZKRjIoYJQWYTd3K/U6zZfS4GDY9yGd2EH4vuYO4kfYtEp/nJ8A+tfAeDo0c9FGxZ0Q+5t5F4/SfwbgyyQzg== integrity sha512-IzmEyl5sNG7NyEFiyFHEHC+sizsZp9MEw1+RJRLX6U5RITvcsEgcajSkHQFafaBPzRrcxZMdm47Cwhl212LXcw==
dependencies: dependencies:
"@sentry/types" "7.61.0" "@sentry/types" "7.64.0"
"@sentry/utils" "7.61.0" "@sentry/utils" "7.64.0"
tslib "^2.4.1 || ^1.9.3" tslib "^2.4.1 || ^1.9.3"
"@sentry/integrations@7.61.0": "@sentry/integrations@7.64.0":
version "7.61.0" version "7.64.0"
resolved "https://registry.yarnpkg.com/@sentry/integrations/-/integrations-7.61.0.tgz#49c97a59ceb0438bd5ec070415d95f8c6c708d5f" resolved "https://registry.yarnpkg.com/@sentry/integrations/-/integrations-7.64.0.tgz#a392ddeebeec0c08ae5ca1f544c80ab15977fe10"
integrity sha512-NEQ+CatBfUM1TmA4FOOyHfsMvSIwSg4pA55Lxiq9quDykzkEtrXFzUfFpZbTunz4cegG8hucPOqbzKFrDPfGjQ== integrity sha512-6gbSGiruOifAmLtXw//Za19GWiL5qugDMEFxSvc5WrBWb+A8UK+foPn3K495OcivLS68AmqAQCUGb+6nlVowwA==
dependencies: dependencies:
"@sentry/types" "7.61.0" "@sentry/types" "7.64.0"
"@sentry/utils" "7.61.0" "@sentry/utils" "7.64.0"
localforage "^1.8.1" localforage "^1.8.1"
tslib "^2.4.1 || ^1.9.3" tslib "^2.4.1 || ^1.9.3"
"@sentry/nextjs@^7.61.0": "@sentry/nextjs@^7.64.0":
version "7.61.0" version "7.64.0"
resolved "https://registry.yarnpkg.com/@sentry/nextjs/-/nextjs-7.61.0.tgz#5a30faa6fb04d9148853edbb5c148dd522126097" resolved "https://registry.yarnpkg.com/@sentry/nextjs/-/nextjs-7.64.0.tgz#5c0bd7ccc6637e0b925dec25ca247dcb8476663c"
integrity sha512-zSEcAITqVmJpR4hhah1jUyCzm/hjlq9vjmO6BmTnQjr84OgOdeKJGWtRdktXId+9zzHdCOehs/JPtmO7y+yG6Q== integrity sha512-hKlIQpFugdRlWj0wcEG9I8JyVm/osdsE72zwMBGnmCw/jf7U63vjOjfxMe/gRuvllCf/AvoGHEkR5jPufcO+bw==
dependencies: dependencies:
"@rollup/plugin-commonjs" "24.0.0" "@rollup/plugin-commonjs" "24.0.0"
"@sentry/core" "7.61.0" "@sentry/core" "7.64.0"
"@sentry/integrations" "7.61.0" "@sentry/integrations" "7.64.0"
"@sentry/node" "7.61.0" "@sentry/node" "7.64.0"
"@sentry/react" "7.61.0" "@sentry/react" "7.64.0"
"@sentry/types" "7.61.0" "@sentry/types" "7.64.0"
"@sentry/utils" "7.61.0" "@sentry/utils" "7.64.0"
"@sentry/webpack-plugin" "1.20.0" "@sentry/webpack-plugin" "1.20.0"
chalk "3.0.0" chalk "3.0.0"
rollup "2.78.0" rollup "2.78.0"
stacktrace-parser "^0.1.10" stacktrace-parser "^0.1.10"
tslib "^2.4.1 || ^1.9.3" tslib "^2.4.1 || ^1.9.3"
"@sentry/node@7.61.0": "@sentry/node@7.64.0":
version "7.61.0" version "7.64.0"
resolved "https://registry.yarnpkg.com/@sentry/node/-/node-7.61.0.tgz#1309330f2ad136af532ad2a03b2a312e885705de" resolved "https://registry.yarnpkg.com/@sentry/node/-/node-7.64.0.tgz#c6f7a67c1442324298f0525e7191bc18572ee1ce"
integrity sha512-oTCqD/h92uvbRCrtCdiAqN6Mfe3vF7ywVHZ8Nq3hHmJp6XadUT+fCBwNQ7rjMyqJAOYAnx/vp6iN9n8C5qcYZQ== integrity sha512-wRi0uTnp1WSa83X2yLD49tV9QPzGh5e42IKdIDBiQ7lV9JhLILlyb34BZY1pq6p4dp35yDasDrP3C7ubn7wo6A==
dependencies: dependencies:
"@sentry-internal/tracing" "7.61.0" "@sentry-internal/tracing" "7.64.0"
"@sentry/core" "7.61.0" "@sentry/core" "7.64.0"
"@sentry/types" "7.61.0" "@sentry/types" "7.64.0"
"@sentry/utils" "7.61.0" "@sentry/utils" "7.64.0"
cookie "^0.4.1" cookie "^0.4.1"
https-proxy-agent "^5.0.0" https-proxy-agent "^5.0.0"
lru_map "^0.3.3" lru_map "^0.3.3"
tslib "^2.4.1 || ^1.9.3" tslib "^2.4.1 || ^1.9.3"
"@sentry/react@7.61.0": "@sentry/react@7.64.0":
version "7.61.0" version "7.64.0"
resolved "https://registry.yarnpkg.com/@sentry/react/-/react-7.61.0.tgz#21dc8eb5168fdb45f994e62738313b50c710d6a4" resolved "https://registry.yarnpkg.com/@sentry/react/-/react-7.64.0.tgz#edee24ac232990204e0fb43dd83994642d4b0f54"
integrity sha512-17ZPDdzx3hzJSHsVFAiw4hUT701LUVIcm568q38sPlSUmnOmNmPeHx/xcQkuxMoVsw/xgf/82B/BKKnIP5/diA== integrity sha512-wOyJUQi7OoT1q+F/fVVv1fzbyO4OYbTu6m1DliLOGQPGEHPBsgPc722smPIExd1/rAMK/FxOuNN5oNhubH8nhg==
dependencies: dependencies:
"@sentry/browser" "7.61.0" "@sentry/browser" "7.64.0"
"@sentry/types" "7.61.0" "@sentry/types" "7.64.0"
"@sentry/utils" "7.61.0" "@sentry/utils" "7.64.0"
hoist-non-react-statics "^3.3.2" hoist-non-react-statics "^3.3.2"
tslib "^2.4.1 || ^1.9.3" tslib "^2.4.1 || ^1.9.3"
"@sentry/replay@7.61.0": "@sentry/replay@7.64.0":
version "7.61.0" version "7.64.0"
resolved "https://registry.yarnpkg.com/@sentry/replay/-/replay-7.61.0.tgz#f816d6a2fc7511877efee2e328681d659433d147" resolved "https://registry.yarnpkg.com/@sentry/replay/-/replay-7.64.0.tgz#bdf09b0c4712f9dc6b24b3ebefa55a4ac76708e6"
integrity sha512-1ugk0yZssOPkSg6uTVcysjxlBydycXiOgV0PCU7DsXCFOV1ua5YpyPZFReTz9iFTtwD0LwGFM1LW9wJeQ67Fzg== integrity sha512-alaMCZDZhaAVmEyiUnszZnvfdbiZx5MmtMTGrlDd7tYq3K5OA9prdLqqlmfIJYBfYtXF3lD0iZFphOZQD+4CIw==
dependencies: dependencies:
"@sentry/core" "7.61.0" "@sentry/core" "7.64.0"
"@sentry/types" "7.61.0" "@sentry/types" "7.64.0"
"@sentry/utils" "7.61.0" "@sentry/utils" "7.64.0"
"@sentry/types@7.61.0": "@sentry/types@7.64.0":
version "7.61.0" version "7.64.0"
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.61.0.tgz#4243b5ef4658f6b0673bc4372c90e6ec920f78d8" resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.64.0.tgz#21fc545ea05c3c8c4c3e518583eca1a8c5429506"
integrity sha512-/GLlIBNR35NKPE/SfWi9W10dK9hE8qTShzsuPVn5wAJxpT3Lb4+dkwmKCTLUYxdkmvRDEudkfOxgalsfQGTAWA== integrity sha512-LqjQprWXjUFRmzIlUjyA+KL+38elgIYmAeoDrdyNVh8MK5IC1W2Lh1Q87b4yOiZeMiIhIVNBd7Ecoh2rodGrGA==
"@sentry/utils@7.61.0": "@sentry/utils@7.64.0":
version "7.61.0" version "7.64.0"
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-7.61.0.tgz#16944afb2b851af045fb528c0c35b7dea3e1cd3b" resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-7.64.0.tgz#6fe3ce9a56d3433ed32119f914907361a54cc184"
integrity sha512-jfj14d0XBFiCU0G6dZZ12SizATiF5Mt4stBGzkM5iS9nXFj8rh1oTT7/p+aZoYzP2JTF+sDzkNjWxyKZkcTo0Q== integrity sha512-HRlM1INzK66Gt+F4vCItiwGKAng4gqzCR4C5marsL3qv6SrKH98dQnCGYgXluSWaaa56h97FRQu7TxCk6jkSvQ==
dependencies: dependencies:
"@sentry/types" "7.61.0" "@sentry/types" "7.64.0"
tslib "^2.4.1 || ^1.9.3" tslib "^2.4.1 || ^1.9.3"
"@sentry/webpack-plugin@1.20.0": "@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" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== 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: err-code@^3.0.1:
version "3.0.1" version "3.0.1"
resolved "https://registry.npmjs.org/err-code/-/err-code-3.0.1.tgz" resolved "https://registry.npmjs.org/err-code/-/err-code-3.0.1.tgz"
@@ -1108,6 +1125,13 @@ iconv-lite@^0.4.24:
dependencies: dependencies:
safer-buffer ">= 2.1.2 < 3" 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: ieee754@^1.1.13, ieee754@^1.2.1:
version "1.2.1" version "1.2.1"
resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz" 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" resolved "https://registry.npmjs.org/jiti/-/jiti-1.19.1.tgz"
integrity sha512-oVhqoRDaBXf7sjkll95LHVS6Myyyb1zaunVwk4Z0+WPSW4gjS0pl01zYKHScTuyEhQsFxV5L4DR5r+YqSyqyyg== 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": "js-tokens@^3.0.0 || ^4.0.0":
version "4.0.0" version "4.0.0"
resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz" 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" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-3.2.0.tgz#fa7877ecbc495c601907562222453c43cc204a5f"
integrity sha512-jczvQbCUS7XmS7o+y1aEO9OBVFeZBQ1MDSEqmO7xSoPgOPoowY/SxLpZ6Vh97/8qHZOteiCKb7gkG9gA2ZUxJA== 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: picocolors@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz" 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" resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz"
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== 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" version "2.1.2"
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==