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
on: [deployment, workflow_dispatch]
on: [workflow_dispatch]
env:
# 384658522150.dkr.ecr.us-east-1.amazonaws.com/reflector

View File

@@ -29,3 +29,11 @@ repos:
hooks:
- id: black
files: ^server/(reflector|tests)/
- repo: https://github.com/pycqa/isort
rev: 5.12.0
hooks:
- id: isort
name: isort (python)
files: ^server/(gpu|evaluate|reflector)/
args: ["--profile", "black", "--filter-files"]

1
server/.gitignore vendored
View File

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

View File

@@ -11,6 +11,29 @@
#DATABASE_URL=postgresql://reflector:reflector@localhost:5432/reflector
## =======================================================
## User authentication
## =======================================================
## No authentication
#AUTH_BACKEND=none
## Using fief (fief.dev)
#AUTH_BACKEND=fief
#AUTH_FIEF_URL=https://your-fief-instance....
#AUTH_FIEF_CLIENT_ID=xxx
#AUTH_FIEF_CLIENT_SECRET=xxx
## =======================================================
## Public mode
## =======================================================
## If set to true, anonymous transcripts will be
## accessible to anybody.
#PUBLIC_MODE=false
## =======================================================
## Transcription backend
##
@@ -45,8 +68,8 @@
## llm backend implementation
## =======================================================
## Use oobagooda (default)
#LLM_BACKEND=oobagooda
## Use oobabooga (default)
#LLM_BACKEND=oobabooga
#LLM_URL=http://xxx:7860/api/generate/v1
## Using serverless modal.com (require reflector-gpu-modal deployed)

View File

@@ -3,14 +3,15 @@ Reflector GPU backend - LLM
===========================
"""
import json
import os
from modal import Image, method, Stub, asgi_app, Secret
from typing import Optional
from modal import Image, Secret, Stub, asgi_app, method
# LLM
LLM_MODEL: str = "lmsys/vicuna-13b-v1.5"
LLM_LOW_CPU_MEM_USAGE: bool = False
LLM_LOW_CPU_MEM_USAGE: bool = True
LLM_TORCH_DTYPE: str = "bfloat16"
LLM_MAX_NEW_TOKENS: int = 300
@@ -49,6 +50,8 @@ llm_image = (
"torch",
"sentencepiece",
"protobuf",
"jsonformer==0.12.0",
"accelerate==0.21.0",
"einops==0.6.1",
"hf-transfer~=0.1",
"huggingface_hub==0.16.4",
@@ -81,6 +84,7 @@ class LLM:
# generation configuration
print("Instance llm generation config")
# JSONFormer doesn't yet support generation configs, but keeping for future usage
model.config.max_new_tokens = LLM_MAX_NEW_TOKENS
gen_cfg = GenerationConfig.from_model_config(model.config)
gen_cfg.max_new_tokens = LLM_MAX_NEW_TOKENS
@@ -107,16 +111,30 @@ class LLM:
return {"status": "ok"}
@method()
def generate(self, prompt: str):
def generate(self, prompt: str, schema: str = None):
print(f"Generate {prompt=}")
# tokenize prompt
input_ids = self.tokenizer.encode(prompt, return_tensors="pt").to(
self.model.device
)
output = self.model.generate(input_ids, generation_config=self.gen_cfg)
# If a schema is given, conform to schema
if schema:
print(f"Schema {schema=}")
import jsonformer
# decode output
response = self.tokenizer.decode(output[0].cpu(), skip_special_tokens=True)
jsonformer_llm = jsonformer.Jsonformer(model=self.model,
tokenizer=self.tokenizer,
json_schema=json.loads(schema),
prompt=prompt,
max_string_token_length=self.gen_cfg.max_new_tokens)
response = jsonformer_llm()
else:
# If no schema, perform prompt only generation
# tokenize prompt
input_ids = self.tokenizer.encode(prompt, return_tensors="pt").to(
self.model.device
)
output = self.model.generate(input_ids, generation_config=self.gen_cfg)
# decode output
response = self.tokenizer.decode(output[0].cpu(), skip_special_tokens=True)
print(f"Generated {response=}")
return {"text": response}
@@ -135,9 +153,9 @@ class LLM:
)
@asgi_app()
def web():
from fastapi import FastAPI, HTTPException, status, Depends
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from pydantic import BaseModel
from pydantic import BaseModel, Field
llmstub = LLM()
@@ -154,12 +172,16 @@ def web():
class LLMRequest(BaseModel):
prompt: str
schema_: Optional[dict] = Field(None, alias="schema")
@app.post("/llm", dependencies=[Depends(apikey_auth)])
async def llm(
req: LLMRequest,
):
func = llmstub.generate.spawn(prompt=req.prompt)
if req.schema_:
func = llmstub.generate.spawn(prompt=req.prompt, schema=json.dumps(req.schema_))
else:
func = llmstub.generate.spawn(prompt=req.prompt)
result = func.get()
return result

View File

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

66
server/poetry.lock generated
View File

@@ -930,6 +930,23 @@ mysql = ["aiomysql"]
postgresql = ["asyncpg"]
sqlite = ["aiosqlite"]
[[package]]
name = "deprecated"
version = "1.2.14"
description = "Python @deprecated decorator to deprecate old python classes, functions or methods."
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
files = [
{file = "Deprecated-1.2.14-py2.py3-none-any.whl", hash = "sha256:6fac8b097794a90302bdbb17b9b815e732d3c4720583ff1b198499d78470466c"},
{file = "Deprecated-1.2.14.tar.gz", hash = "sha256:e5323eb936458dccc2582dc6f9c322c852a775a27065ff2b0c4970b9d53d01b3"},
]
[package.dependencies]
wrapt = ">=1.10,<2"
[package.extras]
dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "sphinx (<2)", "tox"]
[[package]]
name = "dnspython"
version = "2.4.1"
@@ -1022,6 +1039,28 @@ tokenizers = "==0.13.*"
conversion = ["transformers[torch] (>=4.23)"]
dev = ["black (==23.*)", "flake8 (==6.*)", "isort (==5.*)", "pytest (==7.*)"]
[[package]]
name = "fief-client"
version = "0.17.0"
description = "Fief Client for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "fief_client-0.17.0-py3-none-any.whl", hash = "sha256:ecc8674ecaf58fc7d2926f5a0f49fabd3a1a03e278f030977a97ecb716b8884d"},
{file = "fief_client-0.17.0.tar.gz", hash = "sha256:f1f9a10c760c29811a8cce2c1d58938090901772826dda973b67dde1bce3bafd"},
]
[package.dependencies]
fastapi = {version = "*", optional = true, markers = "extra == \"fastapi\""}
httpx = ">=0.21.3,<0.25.0"
jwcrypto = ">=1.4,<2.0.0"
makefun = {version = ">=1.14.0,<2.0.0", optional = true, markers = "extra == \"fastapi\""}
[package.extras]
cli = ["halo"]
fastapi = ["fastapi", "makefun (>=1.14.0,<2.0.0)"]
flask = ["flask"]
[[package]]
name = "filelock"
version = "3.12.2"
@@ -1530,6 +1569,20 @@ files = [
{file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"},
]
[[package]]
name = "jwcrypto"
version = "1.5.0"
description = "Implementation of JOSE Web standards"
optional = false
python-versions = ">= 3.6"
files = [
{file = "jwcrypto-1.5.0.tar.gz", hash = "sha256:2c1dc51cf8e38ddf324795dfe9426dee9dd46caf47f535ccbc18781fba810b8d"},
]
[package.dependencies]
cryptography = ">=3.4"
deprecated = "*"
[[package]]
name = "levenshtein"
version = "0.21.1"
@@ -1662,6 +1715,17 @@ win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""}
[package.extras]
dev = ["Sphinx (==5.3.0)", "colorama (==0.4.5)", "colorama (==0.4.6)", "freezegun (==1.1.0)", "freezegun (==1.2.2)", "mypy (==v0.910)", "mypy (==v0.971)", "mypy (==v0.990)", "pre-commit (==3.2.1)", "pytest (==6.1.2)", "pytest (==7.2.1)", "pytest-cov (==2.12.1)", "pytest-cov (==4.0.0)", "pytest-mypy-plugins (==1.10.1)", "pytest-mypy-plugins (==1.9.3)", "sphinx-autobuild (==2021.3.14)", "sphinx-rtd-theme (==1.2.0)", "tox (==3.27.1)", "tox (==4.4.6)"]
[[package]]
name = "makefun"
version = "1.15.1"
description = "Small library to dynamically create python functions."
optional = false
python-versions = "*"
files = [
{file = "makefun-1.15.1-py2.py3-none-any.whl", hash = "sha256:a63cfc7b47a539c76d97bd4fdb833c7d0461e759fd1225f580cb4be6200294d4"},
{file = "makefun-1.15.1.tar.gz", hash = "sha256:40b0f118b6ded0d8d78c78f1eb679b8b6b2462e3c1b3e05fb1b2da8cd46b48a5"},
]
[[package]]
name = "mpmath"
version = "1.3.0"
@@ -3234,4 +3298,4 @@ multidict = ">=4.0"
[metadata]
lock-version = "2.0"
python-versions = "^3.11"
content-hash = "ea523f9b74581a7867097a6249d416d8836f4daaf33fde65ea343e4d3502c71c"
content-hash = "d84edfea8ac7a849340af8eb5db47df9c13a7cc1c640062ebedb2a808be0de4e"

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -1,18 +1,21 @@
import httpx
from reflector.llm.base import LLM
from reflector.settings import settings
import httpx
class OobagoodaLLM(LLM):
async def _generate(self, prompt: str, **kwargs):
class OobaboogaLLM(LLM):
async def _generate(self, prompt: str, schema: dict | None, **kwargs):
json_payload = {"prompt": prompt}
if schema:
json_payload["schema"] = schema
async with httpx.AsyncClient() as client:
response = await client.post(
settings.LLM_URL,
headers={"Content-Type": "application/json"},
json={"prompt": prompt},
json=json_payload,
)
response.raise_for_status()
return response.json()
LLM.register("oobagooda", OobagoodaLLM)
LLM.register("oobabooga", OobaboogaLLM)

View File

@@ -1,7 +1,7 @@
import httpx
from reflector.llm.base import LLM
from reflector.logger import logger
from reflector.settings import settings
import httpx
class OpenAILLM(LLM):
@@ -15,7 +15,7 @@ class OpenAILLM(LLM):
self.max_tokens = settings.LLM_MAX_TOKENS
logger.info(f"LLM use openai backend at {self.openai_url}")
async def _generate(self, prompt: str, **kwargs) -> str:
async def _generate(self, prompt: str, schema: dict | None, **kwargs) -> str:
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.openai_key}",

View File

@@ -26,13 +26,13 @@ class AudioFileWriterProcessor(Processor):
self.out_stream = self.out_container.add_stream(
"pcm_s16le", rate=data.sample_rate
)
for packet in self.out_stream.encode(data):
self.out_container.mux(packet)
for packet in self.out_stream.encode(data):
self.out_container.mux(packet)
await self.emit(data)
async def _flush(self):
if self.out_container:
for packet in self.out_stream.encode(None):
for packet in self.out_stream.encode():
self.out_container.mux(packet)
self.out_container.close()
self.out_container = None

View File

@@ -5,19 +5,22 @@ API will be a POST request to TRANSCRIPT_URL:
```form
"timestamp": 123.456
"language": "en"
"source_language": "en"
"target_language": "en"
"file": <audio file>
```
"""
from time import monotonic
import httpx
from reflector.processors.audio_transcript import AudioTranscriptProcessor
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.utils.retry import retry
from time import monotonic
import httpx
class AudioTranscriptModalProcessor(AudioTranscriptProcessor):
@@ -26,9 +29,7 @@ class AudioTranscriptModalProcessor(AudioTranscriptProcessor):
self.transcript_url = settings.TRANSCRIPT_URL + "/transcribe"
self.warmup_url = settings.TRANSCRIPT_URL + "/warmup"
self.timeout = settings.TRANSCRIPT_TIMEOUT
self.headers = {
"Authorization": f"Bearer {modal_api_key}",
}
self.headers = {"Authorization": f"Bearer {modal_api_key}"}
async def _warmup(self):
try:
@@ -52,11 +53,28 @@ class AudioTranscriptModalProcessor(AudioTranscriptProcessor):
files = {
"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)(
self.transcript_url,
files=files,
timeout=self.timeout,
headers=self.headers,
json=json_payload,
)
self.logger.debug(
@@ -64,8 +82,14 @@ class AudioTranscriptModalProcessor(AudioTranscriptProcessor):
)
response.raise_for_status()
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(
text=result["text"],
text=text,
words=[
Word(
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.processors.base import Processor
from reflector.processors.types import TitleSummary, Transcript
from reflector.utils.retry import retry
class TranscriptTopicDetectorProcessor(Processor):
@@ -15,9 +15,11 @@ class TranscriptTopicDetectorProcessor(Processor):
PROMPT = """
### Human:
Create a JSON object as response.The JSON object must have 2 fields:
i) title and ii) summary.For the title field,generate a short title
for the given text. For the summary field, summarize the given text
in three sentences.
i) title and ii) summary.
For the title field, generate a short title for the given text.
For the summary field, summarize the given text in a maximum of
three sentences.
{input_text}
@@ -30,6 +32,13 @@ class TranscriptTopicDetectorProcessor(Processor):
self.transcript = None
self.min_transcript_length = min_transcript_length
self.llm = LLM.get_instance()
self.topic_detector_schema = {
"type": "object",
"properties": {
"title": {"type": "string"},
"summary": {"type": "string"},
},
}
async def _warmup(self):
await self.llm.warmup(logger=self.logger)
@@ -52,7 +61,9 @@ class TranscriptTopicDetectorProcessor(Processor):
text = self.transcript.text
self.logger.info(f"Topic detector got {len(text)} length transcript")
prompt = self.PROMPT.format(input_text=text)
result = await retry(self.llm.generate)(prompt=prompt, logger=self.logger)
result = await retry(self.llm.generate)(
prompt=prompt, schema=self.topic_detector_schema, logger=self.logger
)
summary = TitleSummary(
title=result["title"],
summary=result["summary"],

View File

@@ -1,7 +1,8 @@
from pydantic import BaseModel, PrivateAttr
from pathlib import Path
import tempfile
import io
import tempfile
from pathlib import Path
from pydantic import BaseModel, PrivateAttr
class AudioFile(BaseModel):
@@ -104,3 +105,117 @@ class TitleSummary(BaseModel):
class FinalSummary(BaseModel):
summary: str
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
# LLM
# available backend: openai, banana, modal, oobagooda
LLM_BACKEND: str = "oobagooda"
# available backend: openai, banana, modal, oobabooga
LLM_BACKEND: str = "oobabooga"
# LLM common configuration
LLM_URL: str | None = None
@@ -79,5 +79,17 @@ class Settings(BaseSettings):
# Sentry
SENTRY_DSN: str | None = None
# User authentication (none, fief)
AUTH_BACKEND: str = "none"
# User authentication using fief
AUTH_FIEF_URL: str | None = None
AUTH_FIEF_CLIENT_ID: str | None = None
AUTH_FIEF_CLIENT_SECRET: str | None = None
# API public mode
# if set, all anonymous record will be public
PUBLIC_MODE: bool = False
settings = Settings()

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

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"
class LLMTest(LLM):
async def _generate(self, prompt: str, **kwargs) -> str:
async def _generate(self, prompt: str, schema: dict | None, **kwargs) -> str:
return {
"title": "TITLE",
"summary": "SUMMARY",

View File

@@ -1,10 +1,11 @@
import pytest
from httpx import AsyncClient
from reflector.app import app
@pytest.mark.asyncio
async def test_transcript_create():
from reflector.app import app
async with AsyncClient(app=app, base_url="http://test/v1") as ac:
response = await ac.post("/transcripts", json={"name": "test"})
assert response.status_code == 200
@@ -21,6 +22,8 @@ async def test_transcript_create():
@pytest.mark.asyncio
async def test_transcript_get_update_name():
from reflector.app import app
async with AsyncClient(app=app, base_url="http://test/v1") as ac:
response = await ac.post("/transcripts", json={"name": "test"})
assert response.status_code == 200
@@ -42,9 +45,93 @@ async def test_transcript_get_update_name():
@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
# is shared between tests
from reflector.app import app
from reflector.settings import settings
async with AsyncClient(app=app, base_url="http://test/v1") as ac:
response = await ac.get("/transcripts")
assert response.status_code == 401
# if public mode, it should be allowed
try:
settings.PUBLIC_MODE = True
async with AsyncClient(app=app, base_url="http://test/v1") as ac:
response = await ac.get("/transcripts")
assert response.status_code == 200
finally:
settings.PUBLIC_MODE = False
@pytest.fixture
@pytest.mark.asyncio
async def authenticated_client():
from reflector.app import app
from reflector.auth import current_user, current_user_optional
app.dependency_overrides[current_user] = lambda: {"sub": "randomuserid"}
app.dependency_overrides[current_user_optional] = lambda: {"sub": "randomuserid"}
yield
del app.dependency_overrides[current_user]
del app.dependency_overrides[current_user_optional]
@pytest.mark.asyncio
async def test_transcripts_list_authenticated(authenticated_client):
# XXX this test is a bit fragile, as it depends on the storage which
# is shared between tests
from reflector.app import app
async with AsyncClient(app=app, base_url="http://test/v1") as ac:
response = await ac.post("/transcripts", json={"name": "testxx1"})
assert response.status_code == 200
@@ -64,6 +151,8 @@ async def test_transcripts_list():
@pytest.mark.asyncio
async def test_transcript_delete():
from reflector.app import app
async with AsyncClient(app=app, base_url="http://test/v1") as ac:
response = await ac.post("/transcripts", json={"name": "testdel1"})
assert response.status_code == 200

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 try with locked session, RTC should not work
import pytest
import json
from unittest.mock import patch
from httpx import AsyncClient
from reflector.app import app
from uvicorn import Config, Server
import threading
import asyncio
import json
import threading
from pathlib import Path
from unittest.mock import patch
import pytest
from httpx import AsyncClient
from httpx_ws import aconnect_ws
from uvicorn import Config, Server
class ThreadedUvicorn:
@@ -61,7 +60,7 @@ async def dummy_llm():
from reflector.llm.base import LLM
class TestLLM(LLM):
async def _generate(self, prompt: str, **kwargs):
async def _generate(self, prompt: str, schema: dict | None, **kwargs):
return json.dumps({"title": "LLM TITLE", "summary": "LLM SUMMARY"})
with patch("reflector.llm.base.LLM.get_instance") as mock_llm:
@@ -76,6 +75,7 @@ async def test_transcript_rtc_and_websocket(tmpdir, dummy_transcript, dummy_llm)
# to be able to connect with aiortc
from reflector.settings import settings
from reflector.app import app
settings.DATA_DIR = Path(tmpdir)

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:
```
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.

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/TranscriptTopic.ts
models/UpdateTranscript.ts
models/UserInfo.ts
models/ValidationError.ts
models/index.ts
runtime.ts

View File

@@ -55,6 +55,10 @@ export interface V1TranscriptGetAudioRequest {
transcriptId: any;
}
export interface V1TranscriptGetAudioMp3Request {
transcriptId: any;
}
export interface V1TranscriptGetTopicsRequest {
transcriptId: any;
}
@@ -159,6 +163,14 @@ export class DefaultApi extends runtime.BaseAPI {
const headerParameters: runtime.HTTPHeaders = {};
if (this.configuration && this.configuration.accessToken) {
// oauth required
headerParameters["Authorization"] = await this.configuration.accessToken(
"OAuth2AuthorizationCodeBearer",
[],
);
}
const response = await this.request(
{
path: `/v1/transcripts/{transcript_id}`.replace(
@@ -212,6 +224,14 @@ export class DefaultApi extends runtime.BaseAPI {
const headerParameters: runtime.HTTPHeaders = {};
if (this.configuration && this.configuration.accessToken) {
// oauth required
headerParameters["Authorization"] = await this.configuration.accessToken(
"OAuth2AuthorizationCodeBearer",
[],
);
}
const response = await this.request(
{
path: `/v1/transcripts/{transcript_id}`.replace(
@@ -299,6 +319,61 @@ export class DefaultApi extends runtime.BaseAPI {
return await response.value();
}
/**
* Transcript Get Audio Mp3
*/
async v1TranscriptGetAudioMp3Raw(
requestParameters: V1TranscriptGetAudioMp3Request,
initOverrides?: RequestInit | runtime.InitOverrideFunction,
): Promise<runtime.ApiResponse<any>> {
if (
requestParameters.transcriptId === null ||
requestParameters.transcriptId === undefined
) {
throw new runtime.RequiredError(
"transcriptId",
"Required parameter requestParameters.transcriptId was null or undefined when calling v1TranscriptGetAudioMp3.",
);
}
const queryParameters: any = {};
const headerParameters: runtime.HTTPHeaders = {};
const response = await this.request(
{
path: `/v1/transcripts/{transcript_id}/audio/mp3`.replace(
`{${"transcript_id"}}`,
encodeURIComponent(String(requestParameters.transcriptId)),
),
method: "GET",
headers: headerParameters,
query: queryParameters,
},
initOverrides,
);
if (this.isJsonMime(response.headers.get("content-type"))) {
return new runtime.JSONApiResponse<any>(response);
} else {
return new runtime.TextApiResponse(response) as any;
}
}
/**
* Transcript Get Audio Mp3
*/
async v1TranscriptGetAudioMp3(
requestParameters: V1TranscriptGetAudioMp3Request,
initOverrides?: RequestInit | runtime.InitOverrideFunction,
): Promise<any> {
const response = await this.v1TranscriptGetAudioMp3Raw(
requestParameters,
initOverrides,
);
return await response.value();
}
/**
* Transcript Get Topics
*/
@@ -510,6 +585,14 @@ export class DefaultApi extends runtime.BaseAPI {
headerParameters["Content-Type"] = "application/json";
if (this.configuration && this.configuration.accessToken) {
// oauth required
headerParameters["Authorization"] = await this.configuration.accessToken(
"OAuth2AuthorizationCodeBearer",
[],
);
}
const response = await this.request(
{
path: `/v1/transcripts/{transcript_id}`.replace(
@@ -566,6 +649,14 @@ export class DefaultApi extends runtime.BaseAPI {
headerParameters["Content-Type"] = "application/json";
if (this.configuration && this.configuration.accessToken) {
// oauth required
headerParameters["Authorization"] = await this.configuration.accessToken(
"OAuth2AuthorizationCodeBearer",
[],
);
}
const response = await this.request(
{
path: `/v1/transcripts`,
@@ -615,6 +706,14 @@ export class DefaultApi extends runtime.BaseAPI {
const headerParameters: runtime.HTTPHeaders = {};
if (this.configuration && this.configuration.accessToken) {
// oauth required
headerParameters["Authorization"] = await this.configuration.accessToken(
"OAuth2AuthorizationCodeBearer",
[],
);
}
const response = await this.request(
{
path: `/v1/transcripts`,
@@ -643,4 +742,49 @@ export class DefaultApi extends runtime.BaseAPI {
);
return await response.value();
}
/**
* User Me
*/
async v1UserMeRaw(
initOverrides?: RequestInit | runtime.InitOverrideFunction,
): Promise<runtime.ApiResponse<any>> {
const queryParameters: any = {};
const headerParameters: runtime.HTTPHeaders = {};
if (this.configuration && this.configuration.accessToken) {
// oauth required
headerParameters["Authorization"] = await this.configuration.accessToken(
"OAuth2AuthorizationCodeBearer",
[],
);
}
const response = await this.request(
{
path: `/v1/me`,
method: "GET",
headers: headerParameters,
query: queryParameters,
},
initOverrides,
);
if (this.isJsonMime(response.headers.get("content-type"))) {
return new runtime.JSONApiResponse<any>(response);
} else {
return new runtime.TextApiResponse(response) as any;
}
}
/**
* User Me
*/
async v1UserMe(
initOverrides?: RequestInit | runtime.InitOverrideFunction,
): Promise<any> {
const response = await this.v1UserMeRaw(initOverrides);
return await response.value();
}
}

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 "./TranscriptTopic";
export * from "./UpdateTranscript";
export * from "./UserInfo";
export * from "./ValidationError";

View File

@@ -1,11 +1,12 @@
import "./styles/globals.scss";
import { Roboto } from "next/font/google";
import Head from "next/head";
import { Metadata } from "next";
import FiefWrapper from "./(auth)/fiefWrapper";
import UserInfo from "./(auth)/userInfo";
const roboto = Roboto({ subsets: ["latin"], weight: "400" });
export const metadata = {
export const metadata: Metadata = {
title: {
template: "%s Reflector",
default: "Reflector - AI-Powered Meeting Transcriptions by Monadical",
@@ -52,11 +53,22 @@ export const metadata = {
export default function RootLayout({ children }) {
return (
<html lang="en">
<Head>
<title>Test</title>
</Head>
<body className={roboto.className + " flex flex-col min-h-screen"}>
{children}
<FiefWrapper>
<div id="container">
<div className="flex flex-col items-center h-[100svh] bg-gradient-to-r from-[#8ec5fc30] to-[#e0c3fc42]">
<UserInfo />
<div className="h-[13svh] flex flex-col justify-center items-center">
<h1 className="text-5xl font-bold text-blue-500">Reflector</h1>
<p className="text-gray-500">
Capture The Signal, Not The Noise
</p>
</div>
{children}
</div>
</div>
</FiefWrapper>
</body>
</html>
);

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

View File

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

21
www/app/lib/user.ts Normal file
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";
export default async function Index({ params }) {
export default async function Index() {
redirect("/transcripts/new");
}

View File

@@ -3,17 +3,29 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faChevronRight,
faChevronDown,
faLinkSlash,
} from "@fortawesome/free-solid-svg-icons";
import { formatTime } from "../lib/time";
import ScrollToBottom from "./scrollToBottom";
import DisconnectedIndicator from "./disconnectedIndicator";
import LiveTrancription from "./liveTranscription";
import FinalSummary from "./finalSummary";
import { Topic, FinalSummary as FinalSummaryType } from "./webSocketTypes";
type DashboardProps = {
transcriptionText: string;
finalSummary: FinalSummaryType;
topics: Topic[];
disconnected: boolean;
};
export function Dashboard({
transcriptionText,
finalSummary,
topics,
disconnected,
}) {
const [openIndex, setOpenIndex] = useState(null);
const [autoscrollEnabled, setAutoscrollEnabled] = useState(true);
}: DashboardProps) {
const [openIndex, setOpenIndex] = useState<number | null>(null);
const [autoscrollEnabled, setAutoscrollEnabled] = useState<boolean>(true);
useEffect(() => {
if (autoscrollEnabled) scrollToBottom();
@@ -21,7 +33,10 @@ export function Dashboard({
const scrollToBottom = () => {
const topicsDiv = document.getElementById("topics-div");
topicsDiv.scrollTop = topicsDiv.scrollHeight;
if (!topicsDiv)
console.error("Could not find topics div to scroll to bottom");
else topicsDiv.scrollTop = topicsDiv.scrollHeight;
};
const handleScroll = (e) => {
@@ -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 (
<>
<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>
<div
className={`absolute right-5 w-10 h-10 ${
autoscrollEnabled ? "hidden" : "flex"
} ${
finalSummary ? "top-[49%]" : "bottom-1"
} justify-center items-center text-2xl cursor-pointer opacity-70 hover:opacity-100 transition-opacity duration-200 animate-bounce rounded-xl border-slate-400 bg-[#3c82f638] text-[#3c82f6ed]`}
onClick={scrollToBottom}
>
&#11015;
</div>
<ScrollToBottom
visible={!autoscrollEnabled}
hasFinalSummary={finalSummary ? true : false}
handleScrollBottom={scrollToBottom}
/>
<div
id="topics-div"
className="py-2 overflow-y-auto"
@@ -99,26 +98,12 @@ export function Dashboard({
)}
</div>
{finalSummary && (
<div className="min-h-[200px] overflow-y-auto mt-2 p-2 bg-white temp-transcription rounded">
<h2>Final Summary</h2>
<p>{finalSummary.summary}</p>
</div>
)}
{finalSummary.summary && <FinalSummary text={finalSummary.summary} />}
</div>
{disconnected && (
<div className="absolute top-0 left-0 w-full h-full bg-black opacity-50 flex justify-center items-center">
<div className="text-white text-2xl">
<FontAwesomeIcon icon={faLinkSlash} className="mr-2" />
Disconnected
</div>
</div>
)}
{disconnected && <DisconnectedIndicator />}
<footer className="h-[7svh] w-full bg-gray-800 text-white text-center py-4 text-2xl">
&nbsp;{transcriptionText}&nbsp;
</footer>
<LiveTrancription text={transcriptionText} />
</>
);
}

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

View File

@@ -8,7 +8,7 @@ import { faDownload } from "@fortawesome/free-solid-svg-icons";
import Dropdown from "react-dropdown";
import "react-dropdown/style.css";
import CustomRecordPlugin from "./CustomRecordPlugin";
import CustomRecordPlugin from "../lib/CustomRecordPlugin";
import { formatTime } from "../lib/time";
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 { DefaultApi } from "../api/apis/DefaultApi";
import { DefaultApi, V1TranscriptsCreateRequest } from "../api/apis/DefaultApi";
import { Configuration } from "../api/runtime";
import { GetTranscript } from "../api";
import getApi from "../lib/getApi";
const useTranscript = () => {
const [response, setResponse] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
type UseTranscript = {
response: GetTranscript | null;
loading: boolean;
error: string | null;
createTranscript: () => void;
};
const apiConfiguration = new Configuration({
basePath: process.env.NEXT_PUBLIC_API_URL,
});
const api = new DefaultApi(apiConfiguration);
const useTranscript = (): UseTranscript => {
const [response, setResponse] = useState<GetTranscript | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const api = getApi();
const createTranscript = () => {
setLoading(true);
const requestParameters = {
const requestParameters: V1TranscriptsCreateRequest = {
createTranscript: {
name: "Weekly All-Hands", // Hardcoded for now
},

View File

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

View File

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

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;
// Sentry content below
// Injected content via Sentry wizard below
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"
},
"dependencies": {
"@fief/fief": "^0.13.5",
"@fortawesome/fontawesome-svg-core": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fortawesome/react-fontawesome": "^0.2.0",
"@sentry/nextjs": "^7.61.0",
"@sentry/nextjs": "^7.64.0",
"autoprefixer": "10.4.14",
"axios": "^1.4.0",
"fontawesome": "^5.6.3",

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