mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-21 04:39:06 +00:00
Compare commits
8 Commits
igor/daili
...
v0.16.0
| Author | SHA1 | Date | |
|---|---|---|---|
| dc4b737daa | |||
|
|
0baff7abf7 | ||
|
|
962c40e2b6 | ||
|
|
3c4b9f2103 | ||
|
|
c6c035aacf | ||
| c086b91445 | |||
|
|
9a258abc02 | ||
| af86c47f1d |
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: Deploy to Amazon ECS
|
name: Build container/push to container registry
|
||||||
|
|
||||||
on: [workflow_dispatch]
|
on: [workflow_dispatch]
|
||||||
|
|
||||||
|
|||||||
31
CHANGELOG.md
31
CHANGELOG.md
@@ -1,5 +1,36 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.16.0](https://github.com/Monadical-SAS/reflector/compare/v0.15.0...v0.16.0) (2025-10-24)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* search date filter ([#710](https://github.com/Monadical-SAS/reflector/issues/710)) ([962c40e](https://github.com/Monadical-SAS/reflector/commit/962c40e2b6428ac42fd10aea926782d7a6f3f902))
|
||||||
|
|
||||||
|
## [0.15.0](https://github.com/Monadical-SAS/reflector/compare/v0.14.0...v0.15.0) (2025-10-20)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* api tokens ([#705](https://github.com/Monadical-SAS/reflector/issues/705)) ([9a258ab](https://github.com/Monadical-SAS/reflector/commit/9a258abc0209b0ac3799532a507ea6a9125d703a))
|
||||||
|
|
||||||
|
## [0.14.0](https://github.com/Monadical-SAS/reflector/compare/v0.13.1...v0.14.0) (2025-10-08)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Add calendar event data to transcript webhook payload ([#689](https://github.com/Monadical-SAS/reflector/issues/689)) ([5f6910e](https://github.com/Monadical-SAS/reflector/commit/5f6910e5131b7f28f86c9ecdcc57fed8412ee3cd))
|
||||||
|
* container build for www / github ([#672](https://github.com/Monadical-SAS/reflector/issues/672)) ([969bd84](https://github.com/Monadical-SAS/reflector/commit/969bd84fcc14851d1a101412a0ba115f1b7cde82))
|
||||||
|
* docker-compose for production frontend ([#664](https://github.com/Monadical-SAS/reflector/issues/664)) ([5bf64b5](https://github.com/Monadical-SAS/reflector/commit/5bf64b5a41f64535e22849b4bb11734d4dbb4aae))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* restore feature boolean logic ([#671](https://github.com/Monadical-SAS/reflector/issues/671)) ([3660884](https://github.com/Monadical-SAS/reflector/commit/36608849ec64e953e3be456172502762e3c33df9))
|
||||||
|
* security review ([#656](https://github.com/Monadical-SAS/reflector/issues/656)) ([5d98754](https://github.com/Monadical-SAS/reflector/commit/5d98754305c6c540dd194dda268544f6d88bfaf8))
|
||||||
|
* update transcript list on reprocess ([#676](https://github.com/Monadical-SAS/reflector/issues/676)) ([9a71af1](https://github.com/Monadical-SAS/reflector/commit/9a71af145ee9b833078c78d0c684590ab12e9f0e))
|
||||||
|
* upgrade nemo toolkit ([#678](https://github.com/Monadical-SAS/reflector/issues/678)) ([eef6dc3](https://github.com/Monadical-SAS/reflector/commit/eef6dc39037329b65804297786d852dddb0557f9))
|
||||||
|
|
||||||
## [0.13.1](https://github.com/Monadical-SAS/reflector/compare/v0.13.0...v0.13.1) (2025-09-22)
|
## [0.13.1](https://github.com/Monadical-SAS/reflector/compare/v0.13.0...v0.13.1) (2025-09-22)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,29 @@
|
|||||||
|
## API Key Management
|
||||||
|
|
||||||
|
### Finding Your User ID
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get your OAuth sub (user ID) - requires authentication
|
||||||
|
curl -H "Authorization: Bearer <your_jwt>" http://localhost:1250/v1/me
|
||||||
|
# Returns: {"sub": "your-oauth-sub-here", "email": "...", ...}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creating API Keys
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:1250/v1/user/api-keys \
|
||||||
|
-H "Authorization: Bearer <your_jwt>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name": "My API Key"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using API Keys
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use X-API-Key header instead of Authorization
|
||||||
|
curl -H "X-API-Key: <your_api_key>" http://localhost:1250/v1/transcripts
|
||||||
|
```
|
||||||
|
|
||||||
## AWS S3/SQS usage clarification
|
## AWS S3/SQS usage clarification
|
||||||
|
|
||||||
Whereby.com uploads recordings directly to our S3 bucket when meetings end.
|
Whereby.com uploads recordings directly to our S3 bucket when meetings end.
|
||||||
|
|||||||
38
server/migrations/versions/9e3f7b2a4c8e_add_user_api_keys.py
Normal file
38
server/migrations/versions/9e3f7b2a4c8e_add_user_api_keys.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""add user api keys
|
||||||
|
|
||||||
|
Revision ID: 9e3f7b2a4c8e
|
||||||
|
Revises: dc035ff72fd5
|
||||||
|
Create Date: 2025-10-17 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "9e3f7b2a4c8e"
|
||||||
|
down_revision: Union[str, None] = "dc035ff72fd5"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"user_api_key",
|
||||||
|
sa.Column("id", sa.String(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.String(), nullable=False),
|
||||||
|
sa.Column("key_hash", sa.String(), nullable=False),
|
||||||
|
sa.Column("name", sa.String(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
with op.batch_alter_table("user_api_key", schema=None) as batch_op:
|
||||||
|
batch_op.create_index("idx_user_api_key_hash", ["key_hash"], unique=True)
|
||||||
|
batch_op.create_index("idx_user_api_key_user_id", ["user_id"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("user_api_key")
|
||||||
@@ -26,6 +26,7 @@ from reflector.views.transcripts_upload import router as transcripts_upload_rout
|
|||||||
from reflector.views.transcripts_webrtc import router as transcripts_webrtc_router
|
from reflector.views.transcripts_webrtc import router as transcripts_webrtc_router
|
||||||
from reflector.views.transcripts_websocket import router as transcripts_websocket_router
|
from reflector.views.transcripts_websocket import router as transcripts_websocket_router
|
||||||
from reflector.views.user import router as user_router
|
from reflector.views.user import router as user_router
|
||||||
|
from reflector.views.user_api_keys import router as user_api_keys_router
|
||||||
from reflector.views.user_websocket import router as user_ws_router
|
from reflector.views.user_websocket import router as user_ws_router
|
||||||
from reflector.views.whereby import router as whereby_router
|
from reflector.views.whereby import router as whereby_router
|
||||||
from reflector.views.zulip import router as zulip_router
|
from reflector.views.zulip import router as zulip_router
|
||||||
@@ -91,6 +92,7 @@ app.include_router(transcripts_websocket_router, prefix="/v1")
|
|||||||
app.include_router(transcripts_webrtc_router, prefix="/v1")
|
app.include_router(transcripts_webrtc_router, prefix="/v1")
|
||||||
app.include_router(transcripts_process_router, prefix="/v1")
|
app.include_router(transcripts_process_router, prefix="/v1")
|
||||||
app.include_router(user_router, prefix="/v1")
|
app.include_router(user_router, prefix="/v1")
|
||||||
|
app.include_router(user_api_keys_router, prefix="/v1")
|
||||||
app.include_router(user_ws_router, prefix="/v1")
|
app.include_router(user_ws_router, prefix="/v1")
|
||||||
app.include_router(zulip_router, prefix="/v1")
|
app.include_router(zulip_router, prefix="/v1")
|
||||||
app.include_router(whereby_router, prefix="/v1")
|
app.include_router(whereby_router, prefix="/v1")
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
from typing import Annotated, Optional
|
from typing import Annotated, List, Optional
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException
|
from fastapi import Depends, HTTPException
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
from fastapi.security import APIKeyHeader, OAuth2PasswordBearer
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from reflector.db.user_api_keys import user_api_keys_controller
|
||||||
from reflector.logger import logger
|
from reflector.logger import logger
|
||||||
from reflector.settings import settings
|
from reflector.settings import settings
|
||||||
|
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False)
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False)
|
||||||
|
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||||
|
|
||||||
jwt_public_key = open(f"reflector/auth/jwt/keys/{settings.AUTH_JWT_PUBLIC_KEY}").read()
|
jwt_public_key = open(f"reflector/auth/jwt/keys/{settings.AUTH_JWT_PUBLIC_KEY}").read()
|
||||||
jwt_algorithm = settings.AUTH_JWT_ALGORITHM
|
jwt_algorithm = settings.AUTH_JWT_ALGORITHM
|
||||||
@@ -26,7 +28,7 @@ class JWTException(Exception):
|
|||||||
|
|
||||||
class UserInfo(BaseModel):
|
class UserInfo(BaseModel):
|
||||||
sub: str
|
sub: str
|
||||||
email: str
|
email: Optional[str] = None
|
||||||
|
|
||||||
def __getitem__(self, key):
|
def __getitem__(self, key):
|
||||||
return getattr(self, key)
|
return getattr(self, key)
|
||||||
@@ -58,34 +60,53 @@ def authenticated(token: Annotated[str, Depends(oauth2_scheme)]):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def current_user(
|
async def _authenticate_user(
|
||||||
token: Annotated[Optional[str], Depends(oauth2_scheme)],
|
jwt_token: Optional[str],
|
||||||
jwtauth: JWTAuth = Depends(),
|
api_key: Optional[str],
|
||||||
):
|
jwtauth: JWTAuth,
|
||||||
if token is None:
|
) -> UserInfo | None:
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
user_infos: List[UserInfo] = []
|
||||||
|
if api_key:
|
||||||
|
user_api_key = await user_api_keys_controller.verify_key(api_key)
|
||||||
|
if user_api_key:
|
||||||
|
user_infos.append(UserInfo(sub=user_api_key.user_id, email=None))
|
||||||
|
|
||||||
|
if jwt_token:
|
||||||
try:
|
try:
|
||||||
payload = jwtauth.verify_token(token)
|
payload = jwtauth.verify_token(jwt_token)
|
||||||
sub = payload["sub"]
|
sub = payload["sub"]
|
||||||
email = payload["email"]
|
email = payload["email"]
|
||||||
return UserInfo(sub=sub, email=email)
|
user_infos.append(UserInfo(sub=sub, email=email))
|
||||||
except JWTError as e:
|
except JWTError as e:
|
||||||
logger.error(f"JWT error: {e}")
|
logger.error(f"JWT error: {e}")
|
||||||
raise HTTPException(status_code=401, detail="Invalid authentication")
|
raise HTTPException(status_code=401, detail="Invalid authentication")
|
||||||
|
|
||||||
|
if len(user_infos) == 0:
|
||||||
def current_user_optional(
|
|
||||||
token: Annotated[Optional[str], Depends(oauth2_scheme)],
|
|
||||||
jwtauth: JWTAuth = Depends(),
|
|
||||||
):
|
|
||||||
# we accept no token, but if one is provided, it must be a valid one.
|
|
||||||
if token is None:
|
|
||||||
return None
|
return None
|
||||||
try:
|
|
||||||
payload = jwtauth.verify_token(token)
|
if len(set([x.sub for x in user_infos])) > 1:
|
||||||
sub = payload["sub"]
|
raise JWTException(
|
||||||
email = payload["email"]
|
status_code=401,
|
||||||
return UserInfo(sub=sub, email=email)
|
detail="Invalid authentication: more than one user provided",
|
||||||
except JWTError as e:
|
)
|
||||||
logger.error(f"JWT error: {e}")
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid authentication")
|
return user_infos[0]
|
||||||
|
|
||||||
|
|
||||||
|
async def current_user(
|
||||||
|
jwt_token: Annotated[Optional[str], Depends(oauth2_scheme)],
|
||||||
|
api_key: Annotated[Optional[str], Depends(api_key_header)],
|
||||||
|
jwtauth: JWTAuth = Depends(),
|
||||||
|
):
|
||||||
|
user = await _authenticate_user(jwt_token, api_key, jwtauth)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def current_user_optional(
|
||||||
|
jwt_token: Annotated[Optional[str], Depends(oauth2_scheme)],
|
||||||
|
api_key: Annotated[Optional[str], Depends(api_key_header)],
|
||||||
|
jwtauth: JWTAuth = Depends(),
|
||||||
|
):
|
||||||
|
return await _authenticate_user(jwt_token, api_key, jwtauth)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import reflector.db.meetings # noqa
|
|||||||
import reflector.db.recordings # noqa
|
import reflector.db.recordings # noqa
|
||||||
import reflector.db.rooms # noqa
|
import reflector.db.rooms # noqa
|
||||||
import reflector.db.transcripts # noqa
|
import reflector.db.transcripts # noqa
|
||||||
|
import reflector.db.user_api_keys # noqa
|
||||||
|
|
||||||
kwargs = {}
|
kwargs = {}
|
||||||
if "postgres" not in settings.DATABASE_URL:
|
if "postgres" not in settings.DATABASE_URL:
|
||||||
|
|||||||
@@ -135,6 +135,8 @@ class SearchParameters(BaseModel):
|
|||||||
user_id: str | None = None
|
user_id: str | None = None
|
||||||
room_id: str | None = None
|
room_id: str | None = None
|
||||||
source_kind: SourceKind | None = None
|
source_kind: SourceKind | None = None
|
||||||
|
from_datetime: datetime | None = None
|
||||||
|
to_datetime: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class SearchResultDB(BaseModel):
|
class SearchResultDB(BaseModel):
|
||||||
@@ -402,6 +404,14 @@ class SearchController:
|
|||||||
base_query = base_query.where(
|
base_query = base_query.where(
|
||||||
transcripts.c.source_kind == params.source_kind
|
transcripts.c.source_kind == params.source_kind
|
||||||
)
|
)
|
||||||
|
if params.from_datetime:
|
||||||
|
base_query = base_query.where(
|
||||||
|
transcripts.c.created_at >= params.from_datetime
|
||||||
|
)
|
||||||
|
if params.to_datetime:
|
||||||
|
base_query = base_query.where(
|
||||||
|
transcripts.c.created_at <= params.to_datetime
|
||||||
|
)
|
||||||
|
|
||||||
if params.query_text is not None:
|
if params.query_text is not None:
|
||||||
order_by = sqlalchemy.desc(sqlalchemy.text("rank"))
|
order_by = sqlalchemy.desc(sqlalchemy.text("rank"))
|
||||||
|
|||||||
90
server/reflector/db/user_api_keys.py
Normal file
90
server/reflector/db/user_api_keys.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import hmac
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from hashlib import sha256
|
||||||
|
|
||||||
|
import sqlalchemy
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from reflector.db import get_database, metadata
|
||||||
|
from reflector.settings import settings
|
||||||
|
from reflector.utils import generate_uuid4
|
||||||
|
from reflector.utils.string import NonEmptyString
|
||||||
|
|
||||||
|
user_api_keys = sqlalchemy.Table(
|
||||||
|
"user_api_key",
|
||||||
|
metadata,
|
||||||
|
sqlalchemy.Column("id", sqlalchemy.String, primary_key=True),
|
||||||
|
sqlalchemy.Column("user_id", sqlalchemy.String, nullable=False),
|
||||||
|
sqlalchemy.Column("key_hash", sqlalchemy.String, nullable=False),
|
||||||
|
sqlalchemy.Column("name", sqlalchemy.String, nullable=True),
|
||||||
|
sqlalchemy.Column("created_at", sqlalchemy.DateTime(timezone=True), nullable=False),
|
||||||
|
sqlalchemy.Index("idx_user_api_key_hash", "key_hash", unique=True),
|
||||||
|
sqlalchemy.Index("idx_user_api_key_user_id", "user_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserApiKey(BaseModel):
|
||||||
|
id: NonEmptyString = Field(default_factory=generate_uuid4)
|
||||||
|
user_id: NonEmptyString
|
||||||
|
key_hash: NonEmptyString
|
||||||
|
name: NonEmptyString | None = None
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
class UserApiKeyController:
|
||||||
|
@staticmethod
|
||||||
|
def generate_key() -> NonEmptyString:
|
||||||
|
return secrets.token_urlsafe(48)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def hash_key(key: NonEmptyString) -> str:
|
||||||
|
return hmac.new(
|
||||||
|
settings.SECRET_KEY.encode(), key.encode(), digestmod=sha256
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def create_key(
|
||||||
|
cls,
|
||||||
|
user_id: NonEmptyString,
|
||||||
|
name: NonEmptyString | None = None,
|
||||||
|
) -> tuple[UserApiKey, NonEmptyString]:
|
||||||
|
plaintext = cls.generate_key()
|
||||||
|
api_key = UserApiKey(
|
||||||
|
user_id=user_id,
|
||||||
|
key_hash=cls.hash_key(plaintext),
|
||||||
|
name=name,
|
||||||
|
)
|
||||||
|
query = user_api_keys.insert().values(**api_key.model_dump())
|
||||||
|
await get_database().execute(query)
|
||||||
|
return api_key, plaintext
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def verify_key(cls, plaintext_key: NonEmptyString) -> UserApiKey | None:
|
||||||
|
key_hash = cls.hash_key(plaintext_key)
|
||||||
|
query = user_api_keys.select().where(
|
||||||
|
user_api_keys.c.key_hash == key_hash,
|
||||||
|
)
|
||||||
|
result = await get_database().fetch_one(query)
|
||||||
|
return UserApiKey(**result) if result else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def list_by_user_id(user_id: NonEmptyString) -> list[UserApiKey]:
|
||||||
|
query = (
|
||||||
|
user_api_keys.select()
|
||||||
|
.where(user_api_keys.c.user_id == user_id)
|
||||||
|
.order_by(user_api_keys.c.created_at.desc())
|
||||||
|
)
|
||||||
|
results = await get_database().fetch_all(query)
|
||||||
|
return [UserApiKey(**r) for r in results]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def delete_key(key_id: NonEmptyString, user_id: NonEmptyString) -> bool:
|
||||||
|
query = user_api_keys.delete().where(
|
||||||
|
(user_api_keys.c.id == key_id) & (user_api_keys.c.user_id == user_id)
|
||||||
|
)
|
||||||
|
result = await get_database().execute(query)
|
||||||
|
return result > 0
|
||||||
|
|
||||||
|
|
||||||
|
user_api_keys_controller = UserApiKeyController()
|
||||||
@@ -426,7 +426,12 @@ async def task_pipeline_file_process(*, transcript_id: str):
|
|||||||
|
|
||||||
await pipeline.process(audio_file)
|
await pipeline.process(audio_file)
|
||||||
|
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"File pipeline failed for transcript {transcript_id}: {type(e).__name__}: {str(e)}",
|
||||||
|
exc_info=True,
|
||||||
|
transcript_id=transcript_id,
|
||||||
|
)
|
||||||
await pipeline.set_status(transcript_id, "error")
|
await pipeline.set_status(transcript_id, "error")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,16 @@ class FileTranscriptModalProcessor(FileTranscriptProcessor):
|
|||||||
},
|
},
|
||||||
follow_redirects=True,
|
follow_redirects=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
error_body = response.text
|
||||||
|
self.logger.error(
|
||||||
|
"Modal API error",
|
||||||
|
audio_url=data.audio_url,
|
||||||
|
status_code=response.status_code,
|
||||||
|
error_body=error_body,
|
||||||
|
)
|
||||||
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
result = response.json()
|
result = response.json()
|
||||||
|
|
||||||
|
|||||||
@@ -34,8 +34,16 @@ TOPIC_PROMPT = dedent(
|
|||||||
class TopicResponse(BaseModel):
|
class TopicResponse(BaseModel):
|
||||||
"""Structured response for topic detection"""
|
"""Structured response for topic detection"""
|
||||||
|
|
||||||
title: str = Field(description="A descriptive title for the topic being discussed")
|
title: str = Field(
|
||||||
summary: str = Field(description="A concise 1-2 sentence summary of the discussion")
|
description="A descriptive title for the topic being discussed",
|
||||||
|
validation_alias="Title",
|
||||||
|
)
|
||||||
|
summary: str = Field(
|
||||||
|
description="A concise 1-2 sentence summary of the discussion",
|
||||||
|
validation_alias="Summary",
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = {"populate_by_name": True}
|
||||||
|
|
||||||
|
|
||||||
class TranscriptTopicDetectorProcessor(Processor):
|
class TranscriptTopicDetectorProcessor(Processor):
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
|||||||
from fastapi_pagination import Page
|
from fastapi_pagination import Page
|
||||||
from fastapi_pagination.ext.databases import apaginate
|
from fastapi_pagination.ext.databases import apaginate
|
||||||
from jose import jwt
|
from jose import jwt
|
||||||
from pydantic import BaseModel, Field, constr, field_serializer
|
from pydantic import AwareDatetime, BaseModel, Field, constr, field_serializer
|
||||||
|
|
||||||
import reflector.auth as auth
|
import reflector.auth as auth
|
||||||
from reflector.db import get_database
|
from reflector.db import get_database
|
||||||
@@ -133,6 +133,21 @@ SearchOffsetParam = Annotated[
|
|||||||
SearchOffsetBase, Query(description="Number of results to skip")
|
SearchOffsetBase, Query(description="Number of results to skip")
|
||||||
]
|
]
|
||||||
|
|
||||||
|
SearchFromDatetimeParam = Annotated[
|
||||||
|
AwareDatetime | None,
|
||||||
|
Query(
|
||||||
|
alias="from",
|
||||||
|
description="Filter transcripts created on or after this datetime (ISO 8601 with timezone)",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
SearchToDatetimeParam = Annotated[
|
||||||
|
AwareDatetime | None,
|
||||||
|
Query(
|
||||||
|
alias="to",
|
||||||
|
description="Filter transcripts created on or before this datetime (ISO 8601 with timezone)",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class SearchResponse(BaseModel):
|
class SearchResponse(BaseModel):
|
||||||
results: list[SearchResult]
|
results: list[SearchResult]
|
||||||
@@ -174,18 +189,23 @@ async def transcripts_search(
|
|||||||
offset: SearchOffsetParam = 0,
|
offset: SearchOffsetParam = 0,
|
||||||
room_id: Optional[str] = None,
|
room_id: Optional[str] = None,
|
||||||
source_kind: Optional[SourceKind] = None,
|
source_kind: Optional[SourceKind] = None,
|
||||||
|
from_datetime: SearchFromDatetimeParam = None,
|
||||||
|
to_datetime: SearchToDatetimeParam = None,
|
||||||
user: Annotated[
|
user: Annotated[
|
||||||
Optional[auth.UserInfo], Depends(auth.current_user_optional)
|
Optional[auth.UserInfo], Depends(auth.current_user_optional)
|
||||||
] = None,
|
] = None,
|
||||||
):
|
):
|
||||||
"""
|
"""Full-text search across transcript titles and content."""
|
||||||
Full-text search across transcript titles and content.
|
|
||||||
"""
|
|
||||||
if not user and not settings.PUBLIC_MODE:
|
if not user and not settings.PUBLIC_MODE:
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|
||||||
user_id = user["sub"] if user else None
|
user_id = user["sub"] if user else None
|
||||||
|
|
||||||
|
if from_datetime and to_datetime and from_datetime > to_datetime:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="'from' must be less than or equal to 'to'"
|
||||||
|
)
|
||||||
|
|
||||||
search_params = SearchParameters(
|
search_params = SearchParameters(
|
||||||
query_text=parse_search_query_param(q),
|
query_text=parse_search_query_param(q),
|
||||||
limit=limit,
|
limit=limit,
|
||||||
@@ -193,6 +213,8 @@ async def transcripts_search(
|
|||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
room_id=room_id,
|
room_id=room_id,
|
||||||
source_kind=source_kind,
|
source_kind=source_kind,
|
||||||
|
from_datetime=from_datetime,
|
||||||
|
to_datetime=to_datetime,
|
||||||
)
|
)
|
||||||
|
|
||||||
results, total = await search_controller.search_transcripts(search_params)
|
results, total = await search_controller.search_transcripts(search_params)
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ router = APIRouter()
|
|||||||
class UserInfo(BaseModel):
|
class UserInfo(BaseModel):
|
||||||
sub: str
|
sub: str
|
||||||
email: Optional[str]
|
email: Optional[str]
|
||||||
email_verified: Optional[bool]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me")
|
@router.get("/me")
|
||||||
|
|||||||
62
server/reflector/views/user_api_keys.py
Normal file
62
server/reflector/views/user_api_keys.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
import reflector.auth as auth
|
||||||
|
from reflector.db.user_api_keys import user_api_keys_controller
|
||||||
|
from reflector.utils.string import NonEmptyString
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CreateApiKeyRequest(BaseModel):
|
||||||
|
name: NonEmptyString | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKeyResponse(BaseModel):
|
||||||
|
id: NonEmptyString
|
||||||
|
user_id: NonEmptyString
|
||||||
|
name: NonEmptyString | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CreateApiKeyResponse(ApiKeyResponse):
|
||||||
|
key: NonEmptyString
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/user/api-keys", response_model=CreateApiKeyResponse)
|
||||||
|
async def create_api_key(
|
||||||
|
req: CreateApiKeyRequest,
|
||||||
|
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
|
||||||
|
):
|
||||||
|
api_key_model, plaintext = await user_api_keys_controller.create_key(
|
||||||
|
user_id=user["sub"],
|
||||||
|
name=req.name,
|
||||||
|
)
|
||||||
|
return CreateApiKeyResponse(
|
||||||
|
**api_key_model.model_dump(),
|
||||||
|
key=plaintext,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/user/api-keys", response_model=list[ApiKeyResponse])
|
||||||
|
async def list_api_keys(
|
||||||
|
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
|
||||||
|
):
|
||||||
|
api_keys = await user_api_keys_controller.list_by_user_id(user["sub"])
|
||||||
|
return [ApiKeyResponse(**k.model_dump()) for k in api_keys]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/user/api-keys/{key_id}")
|
||||||
|
async def delete_api_key(
|
||||||
|
key_id: NonEmptyString,
|
||||||
|
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
|
||||||
|
):
|
||||||
|
deleted = await user_api_keys_controller.delete_key(key_id, user["sub"])
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404)
|
||||||
|
return {"status": "ok"}
|
||||||
256
server/tests/test_search_date_filtering.py
Normal file
256
server/tests/test_search_date_filtering.py
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from reflector.db import get_database
|
||||||
|
from reflector.db.search import SearchParameters, search_controller
|
||||||
|
from reflector.db.transcripts import SourceKind, transcripts
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestDateRangeIntegration:
|
||||||
|
async def setup_test_transcripts(self):
|
||||||
|
# Use a test user_id that will match in our search parameters
|
||||||
|
test_user_id = "test-user-123"
|
||||||
|
|
||||||
|
test_data = [
|
||||||
|
{
|
||||||
|
"id": "test-before-range",
|
||||||
|
"created_at": datetime(2024, 1, 15, tzinfo=timezone.utc),
|
||||||
|
"title": "Before Range Transcript",
|
||||||
|
"user_id": test_user_id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "test-start-boundary",
|
||||||
|
"created_at": datetime(2024, 6, 1, tzinfo=timezone.utc),
|
||||||
|
"title": "Start Boundary Transcript",
|
||||||
|
"user_id": test_user_id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "test-middle-range",
|
||||||
|
"created_at": datetime(2024, 6, 15, tzinfo=timezone.utc),
|
||||||
|
"title": "Middle Range Transcript",
|
||||||
|
"user_id": test_user_id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "test-end-boundary",
|
||||||
|
"created_at": datetime(2024, 6, 30, 23, 59, 59, tzinfo=timezone.utc),
|
||||||
|
"title": "End Boundary Transcript",
|
||||||
|
"user_id": test_user_id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "test-after-range",
|
||||||
|
"created_at": datetime(2024, 12, 31, tzinfo=timezone.utc),
|
||||||
|
"title": "After Range Transcript",
|
||||||
|
"user_id": test_user_id,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for data in test_data:
|
||||||
|
full_data = {
|
||||||
|
"id": data["id"],
|
||||||
|
"name": data["id"],
|
||||||
|
"status": "ended",
|
||||||
|
"locked": False,
|
||||||
|
"duration": 60.0,
|
||||||
|
"created_at": data["created_at"],
|
||||||
|
"title": data["title"],
|
||||||
|
"short_summary": "Test summary",
|
||||||
|
"long_summary": "Test long summary",
|
||||||
|
"share_mode": "public",
|
||||||
|
"source_kind": SourceKind.FILE,
|
||||||
|
"audio_deleted": False,
|
||||||
|
"reviewed": False,
|
||||||
|
"user_id": data["user_id"],
|
||||||
|
}
|
||||||
|
|
||||||
|
await get_database().execute(transcripts.insert().values(**full_data))
|
||||||
|
|
||||||
|
return test_data
|
||||||
|
|
||||||
|
async def cleanup_test_transcripts(self, test_data):
|
||||||
|
"""Clean up test transcripts."""
|
||||||
|
for data in test_data:
|
||||||
|
await get_database().execute(
|
||||||
|
transcripts.delete().where(transcripts.c.id == data["id"])
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_filter_with_from_datetime_only(self):
|
||||||
|
"""Test filtering with only from_datetime parameter."""
|
||||||
|
test_data = await self.setup_test_transcripts()
|
||||||
|
test_user_id = "test-user-123"
|
||||||
|
|
||||||
|
try:
|
||||||
|
params = SearchParameters(
|
||||||
|
query_text=None,
|
||||||
|
from_datetime=datetime(2024, 6, 1, tzinfo=timezone.utc),
|
||||||
|
to_datetime=None,
|
||||||
|
user_id=test_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
results, total = await search_controller.search_transcripts(params)
|
||||||
|
|
||||||
|
# Should include: start_boundary, middle, end_boundary, after
|
||||||
|
result_ids = [r.id for r in results]
|
||||||
|
assert "test-before-range" not in result_ids
|
||||||
|
assert "test-start-boundary" in result_ids
|
||||||
|
assert "test-middle-range" in result_ids
|
||||||
|
assert "test-end-boundary" in result_ids
|
||||||
|
assert "test-after-range" in result_ids
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await self.cleanup_test_transcripts(test_data)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_filter_with_to_datetime_only(self):
|
||||||
|
"""Test filtering with only to_datetime parameter."""
|
||||||
|
test_data = await self.setup_test_transcripts()
|
||||||
|
test_user_id = "test-user-123"
|
||||||
|
|
||||||
|
try:
|
||||||
|
params = SearchParameters(
|
||||||
|
query_text=None,
|
||||||
|
from_datetime=None,
|
||||||
|
to_datetime=datetime(2024, 6, 30, tzinfo=timezone.utc),
|
||||||
|
user_id=test_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
results, total = await search_controller.search_transcripts(params)
|
||||||
|
|
||||||
|
result_ids = [r.id for r in results]
|
||||||
|
assert "test-before-range" in result_ids
|
||||||
|
assert "test-start-boundary" in result_ids
|
||||||
|
assert "test-middle-range" in result_ids
|
||||||
|
assert "test-end-boundary" not in result_ids
|
||||||
|
assert "test-after-range" not in result_ids
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await self.cleanup_test_transcripts(test_data)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_filter_with_both_datetimes(self):
|
||||||
|
test_data = await self.setup_test_transcripts()
|
||||||
|
test_user_id = "test-user-123"
|
||||||
|
|
||||||
|
try:
|
||||||
|
params = SearchParameters(
|
||||||
|
query_text=None,
|
||||||
|
from_datetime=datetime(2024, 6, 1, tzinfo=timezone.utc),
|
||||||
|
to_datetime=datetime(
|
||||||
|
2024, 7, 1, tzinfo=timezone.utc
|
||||||
|
), # Inclusive of 6/30
|
||||||
|
user_id=test_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
results, total = await search_controller.search_transcripts(params)
|
||||||
|
|
||||||
|
result_ids = [r.id for r in results]
|
||||||
|
assert "test-before-range" not in result_ids
|
||||||
|
assert "test-start-boundary" in result_ids
|
||||||
|
assert "test-middle-range" in result_ids
|
||||||
|
assert "test-end-boundary" in result_ids
|
||||||
|
assert "test-after-range" not in result_ids
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await self.cleanup_test_transcripts(test_data)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_date_filter_with_room_and_source_kind(self):
|
||||||
|
test_data = await self.setup_test_transcripts()
|
||||||
|
test_user_id = "test-user-123"
|
||||||
|
|
||||||
|
try:
|
||||||
|
params = SearchParameters(
|
||||||
|
query_text=None,
|
||||||
|
from_datetime=datetime(2024, 6, 1, tzinfo=timezone.utc),
|
||||||
|
to_datetime=datetime(2024, 7, 1, tzinfo=timezone.utc),
|
||||||
|
source_kind=SourceKind.FILE,
|
||||||
|
room_id=None,
|
||||||
|
user_id=test_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
results, total = await search_controller.search_transcripts(params)
|
||||||
|
|
||||||
|
for result in results:
|
||||||
|
assert result.source_kind == SourceKind.FILE
|
||||||
|
assert result.created_at >= datetime(2024, 6, 1, tzinfo=timezone.utc)
|
||||||
|
assert result.created_at <= datetime(2024, 7, 1, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await self.cleanup_test_transcripts(test_data)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_results_for_future_dates(self):
|
||||||
|
test_data = await self.setup_test_transcripts()
|
||||||
|
test_user_id = "test-user-123"
|
||||||
|
|
||||||
|
try:
|
||||||
|
params = SearchParameters(
|
||||||
|
query_text=None,
|
||||||
|
from_datetime=datetime(2099, 1, 1, tzinfo=timezone.utc),
|
||||||
|
to_datetime=datetime(2099, 12, 31, tzinfo=timezone.utc),
|
||||||
|
user_id=test_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
results, total = await search_controller.search_transcripts(params)
|
||||||
|
|
||||||
|
assert results == []
|
||||||
|
assert total == 0
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await self.cleanup_test_transcripts(test_data)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_date_only_input_handling(self):
|
||||||
|
test_data = await self.setup_test_transcripts()
|
||||||
|
test_user_id = "test-user-123"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Pydantic will parse date-only strings to datetime at midnight
|
||||||
|
from_dt = datetime(2024, 6, 15, 0, 0, 0, tzinfo=timezone.utc)
|
||||||
|
to_dt = datetime(2024, 6, 16, 0, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
params = SearchParameters(
|
||||||
|
query_text=None,
|
||||||
|
from_datetime=from_dt,
|
||||||
|
to_datetime=to_dt,
|
||||||
|
user_id=test_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
results, total = await search_controller.search_transcripts(params)
|
||||||
|
|
||||||
|
result_ids = [r.id for r in results]
|
||||||
|
assert "test-middle-range" in result_ids
|
||||||
|
assert "test-before-range" not in result_ids
|
||||||
|
assert "test-after-range" not in result_ids
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await self.cleanup_test_transcripts(test_data)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDateValidationEdgeCases:
|
||||||
|
"""Edge case tests for datetime validation."""
|
||||||
|
|
||||||
|
def test_timezone_aware_comparison(self):
|
||||||
|
"""Test that timezone-aware comparisons work correctly."""
|
||||||
|
# PST time (UTC-8)
|
||||||
|
pst = timezone(timedelta(hours=-8))
|
||||||
|
pst_dt = datetime(2024, 6, 15, 8, 0, 0, tzinfo=pst)
|
||||||
|
|
||||||
|
# UTC time equivalent (8AM PST = 4PM UTC)
|
||||||
|
utc_dt = datetime(2024, 6, 15, 16, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
assert pst_dt == utc_dt
|
||||||
|
|
||||||
|
def test_mixed_timezone_input(self):
|
||||||
|
"""Test handling mixed timezone inputs."""
|
||||||
|
pst = timezone(timedelta(hours=-8))
|
||||||
|
ist = timezone(timedelta(hours=5, minutes=30))
|
||||||
|
|
||||||
|
from_date = datetime(2024, 6, 15, 0, 0, 0, tzinfo=pst) # PST midnight
|
||||||
|
to_date = datetime(2024, 6, 15, 23, 59, 59, tzinfo=ist) # IST end of day
|
||||||
|
|
||||||
|
assert from_date.tzinfo is not None
|
||||||
|
assert to_date.tzinfo is not None
|
||||||
|
assert from_date < to_date
|
||||||
70
server/tests/test_user_api_keys.py
Normal file
70
server/tests/test_user_api_keys.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from reflector.db.user_api_keys import user_api_keys_controller
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_api_key_creation_and_verification():
|
||||||
|
api_key_model, plaintext = await user_api_keys_controller.create_key(
|
||||||
|
user_id="test_user",
|
||||||
|
name="Test API Key",
|
||||||
|
)
|
||||||
|
|
||||||
|
verified = await user_api_keys_controller.verify_key(plaintext)
|
||||||
|
assert verified is not None
|
||||||
|
assert verified.user_id == "test_user"
|
||||||
|
assert verified.name == "Test API Key"
|
||||||
|
|
||||||
|
invalid = await user_api_keys_controller.verify_key("fake_key")
|
||||||
|
assert invalid is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_api_key_hashing():
|
||||||
|
_, plaintext = await user_api_keys_controller.create_key(
|
||||||
|
user_id="test_user_2",
|
||||||
|
)
|
||||||
|
|
||||||
|
api_keys = await user_api_keys_controller.list_by_user_id("test_user_2")
|
||||||
|
assert len(api_keys) == 1
|
||||||
|
assert api_keys[0].key_hash != plaintext
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_api_key_uniqueness():
|
||||||
|
key1 = user_api_keys_controller.generate_key()
|
||||||
|
key2 = user_api_keys_controller.generate_key()
|
||||||
|
assert key1 != key2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hash_api_key_deterministic():
|
||||||
|
key = "test_key_123"
|
||||||
|
hash1 = user_api_keys_controller.hash_key(key)
|
||||||
|
hash2 = user_api_keys_controller.hash_key(key)
|
||||||
|
assert hash1 == hash2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_by_user_id_empty():
|
||||||
|
api_keys = await user_api_keys_controller.list_by_user_id("nonexistent_user")
|
||||||
|
assert api_keys == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_by_user_id_multiple():
|
||||||
|
user_id = "multi_key_user"
|
||||||
|
|
||||||
|
_, plaintext1 = await user_api_keys_controller.create_key(
|
||||||
|
user_id=user_id,
|
||||||
|
name="API Key 1",
|
||||||
|
)
|
||||||
|
_, plaintext2 = await user_api_keys_controller.create_key(
|
||||||
|
user_id=user_id,
|
||||||
|
name="API Key 2",
|
||||||
|
)
|
||||||
|
|
||||||
|
api_keys = await user_api_keys_controller.list_by_user_id(user_id)
|
||||||
|
assert len(api_keys) == 2
|
||||||
|
names = {k.name for k in api_keys}
|
||||||
|
assert names == {"API Key 1", "API Key 2"}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import Markdown from "react-markdown";
|
import Markdown from "react-markdown";
|
||||||
import "../../../styles/markdown.css";
|
import "../../../styles/markdown.css";
|
||||||
@@ -16,17 +16,15 @@ import {
|
|||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { LuPen } from "react-icons/lu";
|
import { LuPen } from "react-icons/lu";
|
||||||
import { useError } from "../../../(errors)/errorContext";
|
import { useError } from "../../../(errors)/errorContext";
|
||||||
import ShareAndPrivacy from "../shareAndPrivacy";
|
|
||||||
|
|
||||||
type FinalSummaryProps = {
|
type FinalSummaryProps = {
|
||||||
transcriptResponse: GetTranscript;
|
transcript: GetTranscript;
|
||||||
topicsResponse: GetTranscriptTopic[];
|
topics: GetTranscriptTopic[];
|
||||||
onUpdate?: (newSummary) => void;
|
onUpdate: (newSummary: string) => void;
|
||||||
|
finalSummaryRef: React.Dispatch<React.SetStateAction<HTMLDivElement | null>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function FinalSummary(props: FinalSummaryProps) {
|
export default function FinalSummary(props: FinalSummaryProps) {
|
||||||
const finalSummaryRef = useRef<HTMLParagraphElement>(null);
|
|
||||||
|
|
||||||
const [isEditMode, setIsEditMode] = useState(false);
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
const [preEditSummary, setPreEditSummary] = useState("");
|
const [preEditSummary, setPreEditSummary] = useState("");
|
||||||
const [editedSummary, setEditedSummary] = useState("");
|
const [editedSummary, setEditedSummary] = useState("");
|
||||||
@@ -35,10 +33,10 @@ export default function FinalSummary(props: FinalSummaryProps) {
|
|||||||
const updateTranscriptMutation = useTranscriptUpdate();
|
const updateTranscriptMutation = useTranscriptUpdate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEditedSummary(props.transcriptResponse?.long_summary || "");
|
setEditedSummary(props.transcript?.long_summary || "");
|
||||||
}, [props.transcriptResponse?.long_summary]);
|
}, [props.transcript?.long_summary]);
|
||||||
|
|
||||||
if (!props.topicsResponse || !props.transcriptResponse) {
|
if (!props.topics || !props.transcript) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,9 +52,7 @@ export default function FinalSummary(props: FinalSummaryProps) {
|
|||||||
long_summary: newSummary,
|
long_summary: newSummary,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (props.onUpdate) {
|
|
||||||
props.onUpdate(newSummary);
|
props.onUpdate(newSummary);
|
||||||
}
|
|
||||||
console.log("Updated long summary:", updatedTranscript);
|
console.log("Updated long summary:", updatedTranscript);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to update long summary:", err);
|
console.error("Failed to update long summary:", err);
|
||||||
@@ -75,7 +71,7 @@ export default function FinalSummary(props: FinalSummaryProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onSaveClick = () => {
|
const onSaveClick = () => {
|
||||||
updateSummary(editedSummary, props.transcriptResponse.id);
|
updateSummary(editedSummary, props.transcript.id);
|
||||||
setIsEditMode(false);
|
setIsEditMode(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -133,11 +129,6 @@ export default function FinalSummary(props: FinalSummaryProps) {
|
|||||||
>
|
>
|
||||||
<LuPen />
|
<LuPen />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<ShareAndPrivacy
|
|
||||||
finalSummaryRef={finalSummaryRef}
|
|
||||||
transcriptResponse={props.transcriptResponse}
|
|
||||||
topicsResponse={props.topicsResponse}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
@@ -153,7 +144,7 @@ export default function FinalSummary(props: FinalSummaryProps) {
|
|||||||
mt={2}
|
mt={2}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div ref={finalSummaryRef} className="markdown">
|
<div ref={props.finalSummaryRef} className="markdown">
|
||||||
<Markdown>{editedSummary}</Markdown>
|
<Markdown>{editedSummary}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export default function TranscriptDetails(details: TranscriptDetails) {
|
|||||||
waiting || mp3.audioDeleted === true,
|
waiting || mp3.audioDeleted === true,
|
||||||
);
|
);
|
||||||
const useActiveTopic = useState<Topic | null>(null);
|
const useActiveTopic = useState<Topic | null>(null);
|
||||||
|
const [finalSummaryElement, setFinalSummaryElement] =
|
||||||
|
useState<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (waiting) {
|
if (waiting) {
|
||||||
@@ -124,9 +126,12 @@ export default function TranscriptDetails(details: TranscriptDetails) {
|
|||||||
<TranscriptTitle
|
<TranscriptTitle
|
||||||
title={transcript.data?.title || "Unnamed Transcript"}
|
title={transcript.data?.title || "Unnamed Transcript"}
|
||||||
transcriptId={transcriptId}
|
transcriptId={transcriptId}
|
||||||
onUpdate={(newTitle) => {
|
onUpdate={() => {
|
||||||
transcript.refetch().then(() => {});
|
transcript.refetch().then(() => {});
|
||||||
}}
|
}}
|
||||||
|
transcript={transcript.data || null}
|
||||||
|
topics={topics.topics}
|
||||||
|
finalSummaryElement={finalSummaryElement}
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
{mp3.audioDeleted && (
|
{mp3.audioDeleted && (
|
||||||
@@ -148,11 +153,12 @@ export default function TranscriptDetails(details: TranscriptDetails) {
|
|||||||
{transcript.data && topics.topics ? (
|
{transcript.data && topics.topics ? (
|
||||||
<>
|
<>
|
||||||
<FinalSummary
|
<FinalSummary
|
||||||
transcriptResponse={transcript.data}
|
transcript={transcript.data}
|
||||||
topicsResponse={topics.topics}
|
topics={topics.topics}
|
||||||
onUpdate={() => {
|
onUpdate={() => {
|
||||||
transcript.refetch();
|
transcript.refetch().then(() => {});
|
||||||
}}
|
}}
|
||||||
|
finalSummaryRef={setFinalSummaryElement}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ import { useAuth } from "../../lib/AuthProvider";
|
|||||||
import { featureEnabled } from "../../lib/features";
|
import { featureEnabled } from "../../lib/features";
|
||||||
|
|
||||||
type ShareAndPrivacyProps = {
|
type ShareAndPrivacyProps = {
|
||||||
finalSummaryRef: any;
|
finalSummaryElement: HTMLDivElement | null;
|
||||||
transcriptResponse: GetTranscript;
|
transcript: GetTranscript;
|
||||||
topicsResponse: GetTranscriptTopic[];
|
topics: GetTranscriptTopic[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ShareOption = { value: ShareMode; label: string };
|
type ShareOption = { value: ShareMode; label: string };
|
||||||
@@ -48,7 +48,7 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
|
|||||||
const [isOwner, setIsOwner] = useState(false);
|
const [isOwner, setIsOwner] = useState(false);
|
||||||
const [shareMode, setShareMode] = useState<ShareOption>(
|
const [shareMode, setShareMode] = useState<ShareOption>(
|
||||||
shareOptionsData.find(
|
shareOptionsData.find(
|
||||||
(option) => option.value === props.transcriptResponse.share_mode,
|
(option) => option.value === props.transcript.share_mode,
|
||||||
) || shareOptionsData[0],
|
) || shareOptionsData[0],
|
||||||
);
|
);
|
||||||
const [shareLoading, setShareLoading] = useState(false);
|
const [shareLoading, setShareLoading] = useState(false);
|
||||||
@@ -70,7 +70,7 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
|
|||||||
try {
|
try {
|
||||||
const updatedTranscript = await updateTranscriptMutation.mutateAsync({
|
const updatedTranscript = await updateTranscriptMutation.mutateAsync({
|
||||||
params: {
|
params: {
|
||||||
path: { transcript_id: props.transcriptResponse.id },
|
path: { transcript_id: props.transcript.id },
|
||||||
},
|
},
|
||||||
body: requestBody,
|
body: requestBody,
|
||||||
});
|
});
|
||||||
@@ -90,8 +90,8 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
|
|||||||
const userId = auth.status === "authenticated" ? auth.user?.id : null;
|
const userId = auth.status === "authenticated" ? auth.user?.id : null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsOwner(!!(requireLogin && userId === props.transcriptResponse.user_id));
|
setIsOwner(!!(requireLogin && userId === props.transcript.user_id));
|
||||||
}, [userId, props.transcriptResponse.user_id]);
|
}, [userId, props.transcript.user_id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -171,19 +171,19 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
|
|||||||
<Flex gap={2} mb={2}>
|
<Flex gap={2} mb={2}>
|
||||||
{requireLogin && (
|
{requireLogin && (
|
||||||
<ShareZulip
|
<ShareZulip
|
||||||
transcriptResponse={props.transcriptResponse}
|
transcript={props.transcript}
|
||||||
topicsResponse={props.topicsResponse}
|
topics={props.topics}
|
||||||
disabled={toShareMode(shareMode.value) === "private"}
|
disabled={toShareMode(shareMode.value) === "private"}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ShareCopy
|
<ShareCopy
|
||||||
finalSummaryRef={props.finalSummaryRef}
|
finalSummaryElement={props.finalSummaryElement}
|
||||||
transcriptResponse={props.transcriptResponse}
|
transcript={props.transcript}
|
||||||
topicsResponse={props.topicsResponse}
|
topics={props.topics}
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
<ShareLink transcriptId={props.transcriptResponse.id} />
|
<ShareLink transcriptId={props.transcript.id} />
|
||||||
</Dialog.Body>
|
</Dialog.Body>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Positioner>
|
</Dialog.Positioner>
|
||||||
|
|||||||
@@ -5,34 +5,35 @@ type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
|
|||||||
import { Button, BoxProps, Box } from "@chakra-ui/react";
|
import { Button, BoxProps, Box } from "@chakra-ui/react";
|
||||||
|
|
||||||
type ShareCopyProps = {
|
type ShareCopyProps = {
|
||||||
finalSummaryRef: any;
|
finalSummaryElement: HTMLDivElement | null;
|
||||||
transcriptResponse: GetTranscript;
|
transcript: GetTranscript;
|
||||||
topicsResponse: GetTranscriptTopic[];
|
topics: GetTranscriptTopic[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ShareCopy({
|
export default function ShareCopy({
|
||||||
finalSummaryRef,
|
finalSummaryElement,
|
||||||
transcriptResponse,
|
transcript,
|
||||||
topicsResponse,
|
topics,
|
||||||
...boxProps
|
...boxProps
|
||||||
}: ShareCopyProps & BoxProps) {
|
}: ShareCopyProps & BoxProps) {
|
||||||
const [isCopiedSummary, setIsCopiedSummary] = useState(false);
|
const [isCopiedSummary, setIsCopiedSummary] = useState(false);
|
||||||
const [isCopiedTranscript, setIsCopiedTranscript] = useState(false);
|
const [isCopiedTranscript, setIsCopiedTranscript] = useState(false);
|
||||||
|
|
||||||
const onCopySummaryClick = () => {
|
const onCopySummaryClick = () => {
|
||||||
let text_to_copy = finalSummaryRef.current?.innerText;
|
const text_to_copy = finalSummaryElement?.innerText;
|
||||||
|
|
||||||
text_to_copy &&
|
if (text_to_copy) {
|
||||||
navigator.clipboard.writeText(text_to_copy).then(() => {
|
navigator.clipboard.writeText(text_to_copy).then(() => {
|
||||||
setIsCopiedSummary(true);
|
setIsCopiedSummary(true);
|
||||||
// Reset the copied state after 2 seconds
|
// Reset the copied state after 2 seconds
|
||||||
setTimeout(() => setIsCopiedSummary(false), 2000);
|
setTimeout(() => setIsCopiedSummary(false), 2000);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onCopyTranscriptClick = () => {
|
const onCopyTranscriptClick = () => {
|
||||||
let text_to_copy =
|
let text_to_copy =
|
||||||
topicsResponse
|
topics
|
||||||
?.map((topic) => topic.transcript)
|
?.map((topic) => topic.transcript)
|
||||||
.join("\n\n")
|
.join("\n\n")
|
||||||
.replace(/ +/g, " ")
|
.replace(/ +/g, " ")
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ import {
|
|||||||
import { featureEnabled } from "../../lib/features";
|
import { featureEnabled } from "../../lib/features";
|
||||||
|
|
||||||
type ShareZulipProps = {
|
type ShareZulipProps = {
|
||||||
transcriptResponse: GetTranscript;
|
transcript: GetTranscript;
|
||||||
topicsResponse: GetTranscriptTopic[];
|
topics: GetTranscriptTopic[];
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -88,14 +88,14 @@ export default function ShareZulip(props: ShareZulipProps & BoxProps) {
|
|||||||
}, [stream, streams]);
|
}, [stream, streams]);
|
||||||
|
|
||||||
const handleSendToZulip = async () => {
|
const handleSendToZulip = async () => {
|
||||||
if (!props.transcriptResponse) return;
|
if (!props.transcript) return;
|
||||||
|
|
||||||
if (stream && topic) {
|
if (stream && topic) {
|
||||||
try {
|
try {
|
||||||
await postToZulipMutation.mutateAsync({
|
await postToZulipMutation.mutateAsync({
|
||||||
params: {
|
params: {
|
||||||
path: {
|
path: {
|
||||||
transcript_id: props.transcriptResponse.id,
|
transcript_id: props.transcript.id,
|
||||||
},
|
},
|
||||||
query: {
|
query: {
|
||||||
stream,
|
stream,
|
||||||
|
|||||||
@@ -2,14 +2,22 @@ import { useState } from "react";
|
|||||||
import type { components } from "../../reflector-api";
|
import type { components } from "../../reflector-api";
|
||||||
|
|
||||||
type UpdateTranscript = components["schemas"]["UpdateTranscript"];
|
type UpdateTranscript = components["schemas"]["UpdateTranscript"];
|
||||||
|
type GetTranscript = components["schemas"]["GetTranscript"];
|
||||||
|
type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
|
||||||
import { useTranscriptUpdate } from "../../lib/apiHooks";
|
import { useTranscriptUpdate } from "../../lib/apiHooks";
|
||||||
import { Heading, IconButton, Input, Flex, Spacer } from "@chakra-ui/react";
|
import { Heading, IconButton, Input, Flex, Spacer } from "@chakra-ui/react";
|
||||||
import { LuPen } from "react-icons/lu";
|
import { LuPen } from "react-icons/lu";
|
||||||
|
import ShareAndPrivacy from "./shareAndPrivacy";
|
||||||
|
|
||||||
type TranscriptTitle = {
|
type TranscriptTitle = {
|
||||||
title: string;
|
title: string;
|
||||||
transcriptId: string;
|
transcriptId: string;
|
||||||
onUpdate?: (newTitle: string) => void;
|
onUpdate: (newTitle: string) => void;
|
||||||
|
|
||||||
|
// share props
|
||||||
|
transcript: GetTranscript | null;
|
||||||
|
topics: GetTranscriptTopic[] | null;
|
||||||
|
finalSummaryElement: HTMLDivElement | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TranscriptTitle = (props: TranscriptTitle) => {
|
const TranscriptTitle = (props: TranscriptTitle) => {
|
||||||
@@ -29,9 +37,7 @@ const TranscriptTitle = (props: TranscriptTitle) => {
|
|||||||
},
|
},
|
||||||
body: requestBody,
|
body: requestBody,
|
||||||
});
|
});
|
||||||
if (props.onUpdate) {
|
|
||||||
props.onUpdate(newTitle);
|
props.onUpdate(newTitle);
|
||||||
}
|
|
||||||
console.log("Updated transcript title:", newTitle);
|
console.log("Updated transcript title:", newTitle);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to update transcript:", err);
|
console.error("Failed to update transcript:", err);
|
||||||
@@ -62,11 +68,11 @@ const TranscriptTitle = (props: TranscriptTitle) => {
|
|||||||
}
|
}
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
};
|
};
|
||||||
const handleChange = (e) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setDisplayedTitle(e.target.value);
|
setDisplayedTitle(e.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
updateTitle(displayedTitle, props.transcriptId);
|
updateTitle(displayedTitle, props.transcriptId);
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
@@ -111,6 +117,13 @@ const TranscriptTitle = (props: TranscriptTitle) => {
|
|||||||
>
|
>
|
||||||
<LuPen />
|
<LuPen />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
{props.transcript && props.topics && (
|
||||||
|
<ShareAndPrivacy
|
||||||
|
finalSummaryElement={props.finalSummaryElement}
|
||||||
|
transcript={props.transcript}
|
||||||
|
topics={props.topics}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
212
www/app/reflector-api.d.ts
vendored
212
www/app/reflector-api.d.ts
vendored
@@ -4,6 +4,23 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface paths {
|
export interface paths {
|
||||||
|
"/health": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Health */
|
||||||
|
get: operations["health"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/metrics": {
|
"/metrics": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -587,6 +604,41 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/v1/user/api-keys": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** List Api Keys */
|
||||||
|
get: operations["v1_list_api_keys"];
|
||||||
|
put?: never;
|
||||||
|
/** Create Api Key */
|
||||||
|
post: operations["v1_create_api_key"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/v1/user/api-keys/{key_id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
/** Delete Api Key */
|
||||||
|
delete: operations["v1_delete_api_key"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/v1/zulip/streams": {
|
"/v1/zulip/streams": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -648,6 +700,26 @@ export interface paths {
|
|||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
schemas: {
|
schemas: {
|
||||||
|
/** ApiKeyResponse */
|
||||||
|
ApiKeyResponse: {
|
||||||
|
/**
|
||||||
|
* Id
|
||||||
|
* @description A non-empty string
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
/**
|
||||||
|
* User Id
|
||||||
|
* @description A non-empty string
|
||||||
|
*/
|
||||||
|
user_id: string;
|
||||||
|
/** Name */
|
||||||
|
name: string | null;
|
||||||
|
/**
|
||||||
|
* Created At
|
||||||
|
* Format: date-time
|
||||||
|
*/
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
/** AudioWaveform */
|
/** AudioWaveform */
|
||||||
AudioWaveform: {
|
AudioWaveform: {
|
||||||
/** Data */
|
/** Data */
|
||||||
@@ -707,6 +779,36 @@ export interface components {
|
|||||||
*/
|
*/
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
/** CreateApiKeyRequest */
|
||||||
|
CreateApiKeyRequest: {
|
||||||
|
/** Name */
|
||||||
|
name?: string | null;
|
||||||
|
};
|
||||||
|
/** CreateApiKeyResponse */
|
||||||
|
CreateApiKeyResponse: {
|
||||||
|
/**
|
||||||
|
* Id
|
||||||
|
* @description A non-empty string
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
/**
|
||||||
|
* User Id
|
||||||
|
* @description A non-empty string
|
||||||
|
*/
|
||||||
|
user_id: string;
|
||||||
|
/** Name */
|
||||||
|
name: string | null;
|
||||||
|
/**
|
||||||
|
* Created At
|
||||||
|
* Format: date-time
|
||||||
|
*/
|
||||||
|
created_at: string;
|
||||||
|
/**
|
||||||
|
* Key
|
||||||
|
* @description A non-empty string
|
||||||
|
*/
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
/** CreateParticipant */
|
/** CreateParticipant */
|
||||||
CreateParticipant: {
|
CreateParticipant: {
|
||||||
/** Speaker */
|
/** Speaker */
|
||||||
@@ -1431,8 +1533,6 @@ export interface components {
|
|||||||
sub: string;
|
sub: string;
|
||||||
/** Email */
|
/** Email */
|
||||||
email: string | null;
|
email: string | null;
|
||||||
/** Email Verified */
|
|
||||||
email_verified: boolean | null;
|
|
||||||
};
|
};
|
||||||
/** ValidationError */
|
/** ValidationError */
|
||||||
ValidationError: {
|
ValidationError: {
|
||||||
@@ -1509,6 +1609,26 @@ export interface components {
|
|||||||
}
|
}
|
||||||
export type $defs = Record<string, never>;
|
export type $defs = Record<string, never>;
|
||||||
export interface operations {
|
export interface operations {
|
||||||
|
health: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
metrics: {
|
metrics: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2158,6 +2278,10 @@ export interface operations {
|
|||||||
offset?: number;
|
offset?: number;
|
||||||
room_id?: string | null;
|
room_id?: string | null;
|
||||||
source_kind?: components["schemas"]["SourceKind"] | null;
|
source_kind?: components["schemas"]["SourceKind"] | null;
|
||||||
|
/** @description Filter transcripts created on or after this datetime (ISO 8601 with timezone) */
|
||||||
|
from?: string | null;
|
||||||
|
/** @description Filter transcripts created on or before this datetime (ISO 8601 with timezone) */
|
||||||
|
to?: string | null;
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
@@ -2899,6 +3023,90 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
v1_list_api_keys: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiKeyResponse"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
v1_create_api_key: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CreateApiKeyRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CreateApiKeyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
v1_delete_api_key: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
key_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
v1_zulip_get_streams: {
|
v1_zulip_get_streams: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
Reference in New Issue
Block a user