Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 808fc7a00f | |||
| c8b4592d88 | |||
| db071f0dcc | |||
| b1b28fe330 | |||
| 98950f43af | |||
| b43f9922b6 | |||
| d49dc72849 | |||
| 82f0251101 | |||
| 2f675c2a3f | |||
| 7105c7cbae | |||
| 376ff8982c | |||
| a4820ae867 | |||
| 2a56cda55c | |||
| 817cf25b37 | |||
| fdaf567f5d | |||
| 0cf5ab1825 | |||
| c9c5d3b452 | |||
| f0b250739c | |||
| 41c9693d10 | |||
| 83cfcacc0e | |||
| e8618099ac | |||
| ac183c9eb9 | |||
| 3dcc93b630 | |||
| 3614b1e803 | |||
| 5e57647b34 | |||
| e271c87a20 | |||
| 40d1f01277 | |||
| 8477296471 | |||
| d200eeb6bd | |||
| 7a51b09664 |
+1
-1
Submodule secrets updated: 8ef9f4513a...2ef2610071
@@ -0,0 +1,316 @@
|
|||||||
|
|
||||||
|
from rest_framework.decorators import api_view
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from minio import Minio
|
||||||
|
from django.conf import settings
|
||||||
|
import openai
|
||||||
|
import logging
|
||||||
|
from ..models import Room, RoleChoices
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
import smtplib
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_prompt(transcript, date):
|
||||||
|
return f"""
|
||||||
|
|
||||||
|
Audience: Coworkers.
|
||||||
|
|
||||||
|
**Do:**
|
||||||
|
- Detect the language of the transcript and provide your entire response in the same language.
|
||||||
|
- If any part of the transcript is unclear or lacks detail, politely inform the user, specifying which areas need further clarification.
|
||||||
|
- Ensure the accuracy of all information and refrain from adding unverified details.
|
||||||
|
- Format the response using proper markdown and structured sections.
|
||||||
|
- Be concise and avoid repeating yourself between the sections.
|
||||||
|
- Be super precise on nickname
|
||||||
|
- Be a nit-picker
|
||||||
|
- Auto-evaluate your response
|
||||||
|
|
||||||
|
**Don't:**
|
||||||
|
- Write something your are not sure.
|
||||||
|
- Write something that is not mention in the transcript.
|
||||||
|
- Don't make mistake while mentioning someone
|
||||||
|
|
||||||
|
**Task:**
|
||||||
|
Summarize the provided meeting transcript into clear and well-organized meeting minutes. The summary should be structured into the following sections, excluding irrelevant or inapplicable details:
|
||||||
|
|
||||||
|
1. **Summary**: Write a TL;DR of the meeting.
|
||||||
|
2. **Subjects Discussed**: List the key points or issues in bullet points.
|
||||||
|
4. **Next Steps**: Provide action items as bullet points, assigning each task to a responsible individual and including deadlines (if mentioned). Format action items as tickable checkboxes. Ensure every action is assigned and, if a deadline is provided, that it is clearly stated.
|
||||||
|
|
||||||
|
**Transcript**:
|
||||||
|
{transcript}
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
### Summary [Translate this title based on the transcript’s language]
|
||||||
|
[Provide a brief overview of the key points discussed]
|
||||||
|
|
||||||
|
### Subjects Discussed [Translate this title based on the transcript’s language]
|
||||||
|
- [Summarize each topic concisely]
|
||||||
|
|
||||||
|
### Next Steps [Translate this title based on the transcript’s language]
|
||||||
|
- [ ] Action item [Assign to the responsible individual(s) and include a deadline if applicable, follow this strict format: Action - List of owner(s), deadline.]
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_room_and_owners(slug):
|
||||||
|
"""Wip."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
room = Room.objects.get(slug=slug)
|
||||||
|
owner_accesses = room.accesses.filter(role=RoleChoices.OWNER)
|
||||||
|
owners = [access.user for access in owner_accesses]
|
||||||
|
|
||||||
|
logger.info("Room %s has owners: %s", slug, owners)
|
||||||
|
|
||||||
|
except Room.DoesNotExist:
|
||||||
|
logger.error("Room with slug %s does not exist", slug)
|
||||||
|
|
||||||
|
owners = None
|
||||||
|
room = None
|
||||||
|
|
||||||
|
return room, owners
|
||||||
|
|
||||||
|
|
||||||
|
def remove_temporary_file(path):
|
||||||
|
"""Wip."""
|
||||||
|
|
||||||
|
if not path or not os.path.exists(path):
|
||||||
|
return
|
||||||
|
|
||||||
|
os.remove(path)
|
||||||
|
logger.info("Temporary file %s has been deleted.", path)
|
||||||
|
|
||||||
|
def get_blocknote_content(summary):
|
||||||
|
"""Wip."""
|
||||||
|
|
||||||
|
if not settings.BLOCKNOTE_CONVERTER_URL:
|
||||||
|
logger.error("BLOCKNOTE_CONVERTER_URL is not configured")
|
||||||
|
return None
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"markdown": summary
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Converting summary in BlockNote.js…")
|
||||||
|
response = requests.post(settings.BLOCKNOTE_CONVERTER_URL, headers=headers, json=data)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.error(f"Failed to convert summary. Status code: {response.status_code}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
response_data = response.json()
|
||||||
|
if not 'content' in response_data:
|
||||||
|
logger.error(f"Content is missing: %s", response_data)
|
||||||
|
return None
|
||||||
|
|
||||||
|
content = response_data['content']
|
||||||
|
logger.info("Base64 content: %s", content)
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_document_link(content, email):
|
||||||
|
"""Wip."""
|
||||||
|
|
||||||
|
logger.info("Create a document for %s", email)
|
||||||
|
|
||||||
|
if not settings.DOCS_BASE_URL:
|
||||||
|
logger.error("DOCS_BASE_URL is not configured")
|
||||||
|
return None
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"content": content,
|
||||||
|
"owner": email
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Querying docs…")
|
||||||
|
response = requests.post(f"{settings.DOCS_BASE_URL}/api/v1.0/summary/", headers=headers, json=data)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.error(f"Failed to get document's id. Status code: {response.status_code}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
response_data = response.json()
|
||||||
|
if not 'id' in response_data:
|
||||||
|
logger.error(f"ID is missing: %s", response_data)
|
||||||
|
return None
|
||||||
|
|
||||||
|
id = response_data['id']
|
||||||
|
logger.info("Document's id: %s", id)
|
||||||
|
|
||||||
|
return f"{settings.DOCS_BASE_URL}/docs/{id}/"
|
||||||
|
|
||||||
|
|
||||||
|
def email_owner_with_summary(room, link, owner):
|
||||||
|
"""Wip."""
|
||||||
|
|
||||||
|
logger.info("Emailing owner: %s", owner)
|
||||||
|
|
||||||
|
try:
|
||||||
|
room.email_summary(owners=[owner], link=link)
|
||||||
|
except smtplib.SMTPException:
|
||||||
|
logger.error("Error while emailing owner")
|
||||||
|
|
||||||
|
def strip_room_slug(filename):
|
||||||
|
"""Wip."""
|
||||||
|
return filename.split("_")[2].split(".")[0]
|
||||||
|
|
||||||
|
def strip_room_date(filename):
|
||||||
|
"""Wip."""
|
||||||
|
return filename.split("_")[1].split(".")[0]
|
||||||
|
|
||||||
|
|
||||||
|
def get_minio_client():
|
||||||
|
"""Wip."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
return Minio(
|
||||||
|
settings.MINIO_URL,
|
||||||
|
access_key=settings.MINIO_ACCESS_KEY,
|
||||||
|
secret_key=settings.MINIO_SECRET_KEY,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("An error occurred while creating the Minio client %s: %s", settings.MINIO_URL, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def download_temporary_file(minio_client, filename):
|
||||||
|
"""Wip."""
|
||||||
|
|
||||||
|
temp_file_path = None
|
||||||
|
|
||||||
|
logger.info('downloading file %s', filename)
|
||||||
|
|
||||||
|
try:
|
||||||
|
audio_file_stream = minio_client.get_object(settings.MINIO_BUCKET, object_name=filename)
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix='.ogg') as temp_audio_file:
|
||||||
|
|
||||||
|
for data in audio_file_stream.stream(32 * 1024):
|
||||||
|
temp_audio_file.write(data)
|
||||||
|
|
||||||
|
temp_file_path = temp_audio_file.name
|
||||||
|
logger.info('Temporary file created at %s', temp_file_path)
|
||||||
|
|
||||||
|
audio_file_stream.close()
|
||||||
|
audio_file_stream.release_conn()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("An error occurred while accessing the object: %s", str(e))
|
||||||
|
|
||||||
|
return temp_file_path
|
||||||
|
|
||||||
|
|
||||||
|
# todo - discuss retry policy if the webhook fail
|
||||||
|
@api_view(["POST"])
|
||||||
|
def minio_webhook(request):
|
||||||
|
|
||||||
|
data = request.data
|
||||||
|
|
||||||
|
logger.info('Minio webhook sent %s', data)
|
||||||
|
|
||||||
|
record = data["Records"][0]
|
||||||
|
s3 = record['s3']
|
||||||
|
bucket = s3['bucket']
|
||||||
|
bucket_name = bucket['name']
|
||||||
|
object = s3['object']
|
||||||
|
filename = object['key']
|
||||||
|
|
||||||
|
if bucket_name != settings.MINIO_BUCKET:
|
||||||
|
logger.info('Not interested in this bucket: %s', bucket_name)
|
||||||
|
return Response("Not interested in this bucket")
|
||||||
|
|
||||||
|
if object['contentType'] != 'audio/ogg':
|
||||||
|
logger.info('Not interested in this file type: %s', object['contentType'])
|
||||||
|
return Response("Not interested in this file type")
|
||||||
|
|
||||||
|
room_slug = strip_room_slug(filename)
|
||||||
|
room_date = strip_room_date(filename)
|
||||||
|
logger.info('file received %s for room %s', filename, room_slug)
|
||||||
|
|
||||||
|
minio_client = get_minio_client()
|
||||||
|
|
||||||
|
temp_file_path = None
|
||||||
|
summary = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
temp_file_path = download_temporary_file(minio_client, filename)
|
||||||
|
|
||||||
|
if settings.OPENAI_ENABLE and temp_file_path:
|
||||||
|
logger.info('Initiating OpenAI client …')
|
||||||
|
openai_client = openai.OpenAI(
|
||||||
|
api_key=settings.OPENAI_API_KEY,
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(temp_file_path, "rb") as audio_file:
|
||||||
|
logger.info('Querying transcription …')
|
||||||
|
transcript = openai_client.audio.transcriptions.create(
|
||||||
|
model="whisper-1",
|
||||||
|
file=audio_file
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info('Transcript: \n %s', transcript)
|
||||||
|
prompt = get_prompt(transcript.text, room_date)
|
||||||
|
|
||||||
|
logger.info('Prompt: \n %s', prompt)
|
||||||
|
|
||||||
|
summary_response = openai_client.chat.completions.create(
|
||||||
|
model="gpt-4o",
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": "You are a concise and structured assistant, that summarizes meeting transcripts."},
|
||||||
|
{"role": "user", "content": prompt}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = summary_response.choices[0].message.content
|
||||||
|
logger.info('Summary: \n %s', summary)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("An error occurred: %s", str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
finally:
|
||||||
|
remove_temporary_file(temp_file_path)
|
||||||
|
|
||||||
|
if not summary:
|
||||||
|
logger.error("Empty summary.")
|
||||||
|
return Response("")
|
||||||
|
|
||||||
|
room, owners = get_room_and_owners(room_slug)
|
||||||
|
|
||||||
|
if not owners or not room:
|
||||||
|
logger.error("No owners in room %s", room_slug)
|
||||||
|
return Response("")
|
||||||
|
|
||||||
|
content = get_blocknote_content(summary)
|
||||||
|
|
||||||
|
if not content:
|
||||||
|
logger.error("Empty content.")
|
||||||
|
return Response("")
|
||||||
|
|
||||||
|
owner = owners[0]
|
||||||
|
|
||||||
|
link = get_document_link(content, owner.email)
|
||||||
|
|
||||||
|
if not link:
|
||||||
|
logger.error("Empty link.")
|
||||||
|
return Response("")
|
||||||
|
|
||||||
|
email_owner_with_summary(room, link, owner)
|
||||||
|
|
||||||
|
return Response("")
|
||||||
@@ -15,6 +15,8 @@ from django.utils.functional import lazy
|
|||||||
from django.utils.text import capfirst, slugify
|
from django.utils.text import capfirst, slugify
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
|
||||||
from timezone_field import TimeZoneField
|
from timezone_field import TimeZoneField
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
@@ -325,3 +327,24 @@ class Room(Resource):
|
|||||||
else:
|
else:
|
||||||
raise ValidationError({"name": f'Room name "{self.name:s}" is reserved.'})
|
raise ValidationError({"name": f'Room name "{self.name:s}" is reserved.'})
|
||||||
super().clean_fields(exclude=exclude)
|
super().clean_fields(exclude=exclude)
|
||||||
|
|
||||||
|
|
||||||
|
def email_summary(self, owners, link):
|
||||||
|
"""Wip"""
|
||||||
|
|
||||||
|
template_vars = {
|
||||||
|
"title": "Votre résumé est prêt",
|
||||||
|
"link": link,
|
||||||
|
"room": self.slug,
|
||||||
|
}
|
||||||
|
msg_html = render_to_string("mail/html/summary.html", template_vars)
|
||||||
|
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
|
||||||
|
|
||||||
|
for owner in owners:
|
||||||
|
owner.email_user(
|
||||||
|
subject="Votre résumé est prêt",
|
||||||
|
from_email=settings.EMAIL_FROM,
|
||||||
|
message=msg_plain,
|
||||||
|
html_message=msg_html,
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from django.urls import include, path
|
|||||||
|
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
from core.api import get_frontend_configuration, viewsets
|
from core.api import get_frontend_configuration, viewsets, demo
|
||||||
from core.authentication.urls import urlpatterns as oidc_urls
|
from core.authentication.urls import urlpatterns as oidc_urls
|
||||||
|
|
||||||
# - Main endpoints
|
# - Main endpoints
|
||||||
@@ -24,6 +24,7 @@ urlpatterns = [
|
|||||||
*router.urls,
|
*router.urls,
|
||||||
*oidc_urls,
|
*oidc_urls,
|
||||||
path("config/", get_frontend_configuration, name="config"),
|
path("config/", get_frontend_configuration, name="config"),
|
||||||
|
path("minio-webhook/", demo.minio_webhook, name="demo"),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
|
|||||||
room=room,
|
room=room,
|
||||||
room_join=True,
|
room_join=True,
|
||||||
room_admin=True,
|
room_admin=True,
|
||||||
|
room_record=True,
|
||||||
can_update_own_metadata=True,
|
can_update_own_metadata=True,
|
||||||
can_publish_sources=[
|
can_publish_sources=[
|
||||||
"camera",
|
"camera",
|
||||||
|
|||||||
@@ -386,6 +386,53 @@ class Base(Configuration):
|
|||||||
None, environ_name="ANALYTICS_KEY", environ_prefix=None
|
None, environ_name="ANALYTICS_KEY", environ_prefix=None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# todo - totally wip
|
||||||
|
AWS_S3_ENDPOINT_URL = values.Value(
|
||||||
|
environ_name="AWS_S3_ENDPOINT_URL", environ_prefix=None
|
||||||
|
)
|
||||||
|
AWS_S3_ACCESS_KEY_ID = values.Value(
|
||||||
|
environ_name="AWS_S3_ACCESS_KEY_ID", environ_prefix=None
|
||||||
|
)
|
||||||
|
AWS_S3_SECRET_ACCESS_KEY = values.Value(
|
||||||
|
environ_name="AWS_S3_SECRET_ACCESS_KEY", environ_prefix=None
|
||||||
|
)
|
||||||
|
AWS_S3_REGION_NAME = values.Value(
|
||||||
|
environ_name="AWS_S3_REGION_NAME", environ_prefix=None
|
||||||
|
)
|
||||||
|
AWS_STORAGE_BUCKET_NAME = values.Value(
|
||||||
|
"meet-media-storage",
|
||||||
|
environ_name="AWS_STORAGE_BUCKET_NAME",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
OPENAI_API_KEY = values.Value(
|
||||||
|
None, environ_name="OPENAI_API_KEY", environ_prefix=None
|
||||||
|
)
|
||||||
|
OPENAI_ENABLE = values.BooleanValue(
|
||||||
|
True, environ_name="OPENAI_ENABLE", environ_prefix=None
|
||||||
|
)
|
||||||
|
|
||||||
|
# todo - totally wip
|
||||||
|
MINIO_ACCESS_KEY = values.Value(
|
||||||
|
None, environ_name="MINIO_ACCESS_KEY", environ_prefix=None
|
||||||
|
)
|
||||||
|
MINIO_SECRET_KEY = values.Value(
|
||||||
|
None, environ_name="MINIO_SECRET_KEY", environ_prefix=None
|
||||||
|
)
|
||||||
|
MINIO_URL = values.Value(
|
||||||
|
None, environ_name="MINIO_URL", environ_prefix=None
|
||||||
|
)
|
||||||
|
MINIO_BUCKET = values.Value(
|
||||||
|
'livekit-staging-livekit-egress', environ_name="MINIO_BUCKET", environ_prefix=None
|
||||||
|
)
|
||||||
|
|
||||||
|
BLOCKNOTE_CONVERTER_URL = values.Value(
|
||||||
|
'https://converter-blocknote.osc-fr1.scalingo.io/', environ_name="", environ_prefix=None
|
||||||
|
)
|
||||||
|
DOCS_BASE_URL = values.Value(
|
||||||
|
'https://docs-ia.beta.numerique.gouv.fr', environ_name="", environ_prefix=None
|
||||||
|
)
|
||||||
|
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
@property
|
@property
|
||||||
def ENVIRONMENT(self):
|
def ENVIRONMENT(self):
|
||||||
@@ -529,6 +576,20 @@ class Production(Base):
|
|||||||
ALLOWED_HOSTS=["foo.com", "foo.fr"]
|
ALLOWED_HOSTS=["foo.com", "foo.fr"]
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
LOGGING = {
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"handlers": {
|
||||||
|
"console": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"handlers": ["console"],
|
||||||
|
"level": "INFO",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
# Security
|
# Security
|
||||||
ALLOWED_HOSTS = [
|
ALLOWED_HOSTS = [
|
||||||
*values.ListValue([], environ_name="ALLOWED_HOSTS"),
|
*values.ListValue([], environ_name="ALLOWED_HOSTS"),
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ dependencies = [
|
|||||||
"whitenoise==6.7.0",
|
"whitenoise==6.7.0",
|
||||||
"mozilla-django-oidc==4.0.1",
|
"mozilla-django-oidc==4.0.1",
|
||||||
"livekit-api==0.7.0",
|
"livekit-api==0.7.0",
|
||||||
|
"minio==7.2.9",
|
||||||
|
"openai==1.51.2"
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { RoomEvent } from 'livekit-client'
|
||||||
|
import { egressStore } from '@/stores/egress.ts'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
|
||||||
|
export const RecordingIndicator = () => {
|
||||||
|
const room = useRoomContext()
|
||||||
|
const [isRecording, setIsRecording] = useState(room.isRecording)
|
||||||
|
|
||||||
|
const egressSnap = useSnapshot(egressStore)
|
||||||
|
const egressIsStopping = egressSnap.egressIsStopping
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleRecordingStatusChanges = (isRecording: boolean) => {
|
||||||
|
if (!isRecording) {
|
||||||
|
egressStore.egressIsStopping = false
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRecording(isRecording)
|
||||||
|
}
|
||||||
|
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanges)
|
||||||
|
return () => {
|
||||||
|
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanges)
|
||||||
|
}
|
||||||
|
}, [room])
|
||||||
|
|
||||||
|
const getStatus = () => {
|
||||||
|
if (egressIsStopping) {
|
||||||
|
return 'saving recording'
|
||||||
|
}
|
||||||
|
if (isRecording) {
|
||||||
|
return 'recording'
|
||||||
|
}
|
||||||
|
if (!isRecording) {
|
||||||
|
return 'available'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: 'column',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Room status: {getStatus()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { fetchServerApi } from './fetchServerApi'
|
||||||
|
import { buildServerApiUrl } from './buildServerApiUrl'
|
||||||
|
import { useRoomData } from '../hooks/useRoomData'
|
||||||
|
import { useParams } from 'wouter'
|
||||||
|
|
||||||
|
export const useRecordRoom = () => {
|
||||||
|
const data = useRoomData()
|
||||||
|
const { roomId: roomSlug } = useParams()
|
||||||
|
|
||||||
|
const recordRoom = () => {
|
||||||
|
if (!data || !data?.livekit) {
|
||||||
|
throw new Error('Room data is not available')
|
||||||
|
}
|
||||||
|
if (!roomSlug) {
|
||||||
|
throw new Error('Room ID is not available')
|
||||||
|
}
|
||||||
|
return fetchServerApi(
|
||||||
|
buildServerApiUrl(
|
||||||
|
data.livekit.url,
|
||||||
|
'/twirp/livekit.Egress/StartRoomCompositeEgress'
|
||||||
|
),
|
||||||
|
data.livekit.token,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
room_name: data.livekit.room,
|
||||||
|
audio_only: true,
|
||||||
|
file_outputs: [
|
||||||
|
{
|
||||||
|
file_extension: 'ogg',
|
||||||
|
filepath: `{room_name}_{time}_${roomSlug}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopRecordingRoom = (egressId: string) => {
|
||||||
|
if (!data || !data?.livekit) {
|
||||||
|
throw new Error('Room data is not available')
|
||||||
|
}
|
||||||
|
return fetchServerApi(
|
||||||
|
buildServerApiUrl(data.livekit.url, '/twirp/livekit.Egress/StopEgress'),
|
||||||
|
data.livekit.token,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
egressId,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { recordRoom, stopRecordingRoom }
|
||||||
|
}
|
||||||
+2
@@ -10,6 +10,7 @@ import { Dispatch, SetStateAction } from 'react'
|
|||||||
import { DialogState } from './OptionsButton'
|
import { DialogState } from './OptionsButton'
|
||||||
import { Separator } from '@/primitives/Separator'
|
import { Separator } from '@/primitives/Separator'
|
||||||
import { useSidePanel } from '../../../hooks/useSidePanel'
|
import { useSidePanel } from '../../../hooks/useSidePanel'
|
||||||
|
import { RecordingMenuItem } from '@/features/rooms/livekit/components/controls/Options/RecordingMenuItem'
|
||||||
|
|
||||||
// @todo try refactoring it to use MenuList component
|
// @todo try refactoring it to use MenuList component
|
||||||
export const OptionsMenuItems = ({
|
export const OptionsMenuItems = ({
|
||||||
@@ -34,6 +35,7 @@ export const OptionsMenuItems = ({
|
|||||||
<RiAccountBoxLine size={20} />
|
<RiAccountBoxLine size={20} />
|
||||||
{t('effects')}
|
{t('effects')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
<RecordingMenuItem />
|
||||||
</Section>
|
</Section>
|
||||||
<Separator />
|
<Separator />
|
||||||
<Section>
|
<Section>
|
||||||
|
|||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
import { MenuItem } from 'react-aria-components'
|
||||||
|
import { menuItemRecipe } from '@/primitives/menuItemRecipe.ts'
|
||||||
|
import { RiPauseCircleLine, RiRecordCircleLine } from '@remixicon/react'
|
||||||
|
import { useRecordRoom } from '@/features/rooms/livekit/api/recordRoom.ts'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { egressStore } from '@/stores/egress.ts'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
|
||||||
|
export const RecordingMenuItem = () => {
|
||||||
|
const { recordRoom, stopRecordingRoom } = useRecordRoom()
|
||||||
|
|
||||||
|
const egressSnap = useSnapshot(egressStore)
|
||||||
|
const egressId = egressSnap.egressId
|
||||||
|
|
||||||
|
const [isPending, setIsPending] = useState(false)
|
||||||
|
|
||||||
|
const handleAction = async () => {
|
||||||
|
if (egressId) {
|
||||||
|
setIsPending(true)
|
||||||
|
egressStore.egressIsStopping = true
|
||||||
|
const response = await stopRecordingRoom(egressId)
|
||||||
|
console.log(response)
|
||||||
|
egressStore.egressId = undefined
|
||||||
|
setIsPending(false)
|
||||||
|
} else {
|
||||||
|
setIsPending(true)
|
||||||
|
const response = await recordRoom()
|
||||||
|
egressStore.egressId = response['egress_id'] as string
|
||||||
|
setIsPending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
isDisabled={isPending}
|
||||||
|
className={menuItemRecipe({ icon: true })}
|
||||||
|
onAction={handleAction}
|
||||||
|
>
|
||||||
|
{egressId ? (
|
||||||
|
<>
|
||||||
|
<RiPauseCircleLine size={18} />
|
||||||
|
Arrêter l'enregistrement
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RiRecordCircleLine size={18} />
|
||||||
|
Enregistrer la réunion
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ import { FocusLayout } from '../components/FocusLayout'
|
|||||||
import { ParticipantTile } from '../components/ParticipantTile'
|
import { ParticipantTile } from '../components/ParticipantTile'
|
||||||
import { SidePanel } from '../components/SidePanel'
|
import { SidePanel } from '../components/SidePanel'
|
||||||
import { useSidePanel } from '../hooks/useSidePanel'
|
import { useSidePanel } from '../hooks/useSidePanel'
|
||||||
|
import { RecordingIndicator } from '@/features/rooms/components/RecordingIndicator.tsx'
|
||||||
|
|
||||||
const LayoutWrapper = styled(
|
const LayoutWrapper = styled(
|
||||||
'div',
|
'div',
|
||||||
@@ -165,6 +166,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
transition: 'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms',
|
transition: 'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<RecordingIndicator />
|
||||||
<LayoutWrapper>
|
<LayoutWrapper>
|
||||||
<div
|
<div
|
||||||
style={{ display: 'flex', position: 'relative', width: '100%' }}
|
style={{ display: 'flex', position: 'relative', width: '100%' }}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { proxy } from 'valtio'
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
egressId: string | undefined
|
||||||
|
egressIsStopping: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const egressStore = proxy<State>({
|
||||||
|
egressId: undefined,
|
||||||
|
egressIsStopping: false,
|
||||||
|
})
|
||||||
@@ -99,6 +99,23 @@ backend:
|
|||||||
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
|
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
|
||||||
FRONTEND_ANALYTICS: "{'id': 'phc_RPYko028Oqtj0c9exLIWwrlrjLxSdxT0ntW0Lam4iom', 'host': 'https://product.visio.numerique.gouv.fr'}"
|
FRONTEND_ANALYTICS: "{'id': 'phc_RPYko028Oqtj0c9exLIWwrlrjLxSdxT0ntW0Lam4iom', 'host': 'https://product.visio.numerique.gouv.fr'}"
|
||||||
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041'}"
|
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041'}"
|
||||||
|
AWS_S3_ENDPOINT_URL:
|
||||||
|
secretKeyRef:
|
||||||
|
name: impress-media-storage.bucket.libre.sh
|
||||||
|
key: url
|
||||||
|
AWS_S3_ACCESS_KEY_ID:
|
||||||
|
secretKeyRef:
|
||||||
|
name: impress-media-storage.bucket.libre.sh
|
||||||
|
key: accessKey
|
||||||
|
AWS_S3_SECRET_ACCESS_KEY:
|
||||||
|
secretKeyRef:
|
||||||
|
name: impress-media-storage.bucket.libre.sh
|
||||||
|
key: secretKey
|
||||||
|
AWS_STORAGE_BUCKET_NAME:
|
||||||
|
secretKeyRef:
|
||||||
|
name: impress-media-storage.bucket.libre.sh
|
||||||
|
key: bucket
|
||||||
|
AWS_S3_REGION_NAME: local
|
||||||
|
|
||||||
createsuperuser:
|
createsuperuser:
|
||||||
command:
|
command:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
image:
|
image:
|
||||||
repository: lasuite/meet-backend
|
repository: lasuite/meet-backend
|
||||||
pullPolicy: Always
|
pullPolicy: Always
|
||||||
tag: "main"
|
tag: "v-hackathon"
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
migrateJobAnnotations:
|
migrateJobAnnotations:
|
||||||
@@ -97,6 +97,41 @@ backend:
|
|||||||
ALLOW_UNREGISTERED_ROOMS: False
|
ALLOW_UNREGISTERED_ROOMS: False
|
||||||
FRONTEND_ANALYTICS: "{'id': 'phc_RPYko028Oqtj0c9exLIWwrlrjLxSdxT0ntW0Lam4iom', 'host': 'https://product.visio-staging.beta.numerique.gouv.fr'}"
|
FRONTEND_ANALYTICS: "{'id': 'phc_RPYko028Oqtj0c9exLIWwrlrjLxSdxT0ntW0Lam4iom', 'host': 'https://product.visio-staging.beta.numerique.gouv.fr'}"
|
||||||
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041'}"
|
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041'}"
|
||||||
|
AWS_S3_ENDPOINT_URL:
|
||||||
|
secretKeyRef:
|
||||||
|
name: meet-media-storage.bucket.libre.sh
|
||||||
|
key: url
|
||||||
|
AWS_S3_ACCESS_KEY_ID:
|
||||||
|
secretKeyRef:
|
||||||
|
name: meet-media-storage.bucket.libre.sh
|
||||||
|
key: accessKey
|
||||||
|
AWS_S3_SECRET_ACCESS_KEY:
|
||||||
|
secretKeyRef:
|
||||||
|
name: meet-media-storage.bucket.libre.sh
|
||||||
|
key: secretKey
|
||||||
|
AWS_STORAGE_BUCKET_NAME:
|
||||||
|
secretKeyRef:
|
||||||
|
name: meet-media-storage.bucket.libre.sh
|
||||||
|
key: bucket
|
||||||
|
AWS_S3_REGION_NAME: local
|
||||||
|
OPENAI_API_KEY:
|
||||||
|
secretKeyRef:
|
||||||
|
name: backend
|
||||||
|
key: OPENAI_API_KEY
|
||||||
|
OPENAI_ENABLE: True
|
||||||
|
MINIO_ACCESS_KEY:
|
||||||
|
secretKeyRef:
|
||||||
|
name: backend
|
||||||
|
key: MINIO_ACCESS_KEY
|
||||||
|
MINIO_SECRET_KEY:
|
||||||
|
secretKeyRef:
|
||||||
|
name: backend
|
||||||
|
key: MINIO_SECRET_KEY
|
||||||
|
MINIO_URL:
|
||||||
|
secretKeyRef:
|
||||||
|
name: backend
|
||||||
|
key: MINIO_URL
|
||||||
|
|
||||||
|
|
||||||
createsuperuser:
|
createsuperuser:
|
||||||
command:
|
command:
|
||||||
@@ -110,7 +145,7 @@ frontend:
|
|||||||
image:
|
image:
|
||||||
repository: lasuite/meet-frontend
|
repository: lasuite/meet-frontend
|
||||||
pullPolicy: Always
|
pullPolicy: Always
|
||||||
tag: "main"
|
tag: "v-hackathon"
|
||||||
|
|
||||||
ingress:
|
ingress:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
apiVersion: core.libre.sh/v1alpha1
|
||||||
|
kind: Bucket
|
||||||
|
metadata:
|
||||||
|
name: meet-media-storage
|
||||||
|
namespace: {{ .Release.Namespace | quote }}
|
||||||
|
spec:
|
||||||
|
provider: data
|
||||||
|
versioned: true
|
||||||
@@ -15,3 +15,16 @@ stringData:
|
|||||||
OIDC_RP_CLIENT_SECRET: {{ .Values.oidc.clientSecret }}
|
OIDC_RP_CLIENT_SECRET: {{ .Values.oidc.clientSecret }}
|
||||||
LIVEKIT_API_SECRET: {{ .Values.livekitApi.secret }}
|
LIVEKIT_API_SECRET: {{ .Values.livekitApi.secret }}
|
||||||
LIVEKIT_API_KEY: {{ .Values.livekitApi.key }}
|
LIVEKIT_API_KEY: {{ .Values.livekitApi.key }}
|
||||||
|
{{- if .Values.openaiApiKey }}
|
||||||
|
OPENAI_API_KEY: {{ .Values.openaiApiKey }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.minioAccessKey }}
|
||||||
|
MINIO_ACCESS_KEY: {{ .Values.minioAccessKey }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.minioSecretKey }}
|
||||||
|
MINIO_SECRET_KEY: {{ .Values.minioSecretKey }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.minioUrl }}
|
||||||
|
MINIO_URL: {{ .Values.minioUrl }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<mjml>
|
||||||
|
<mj-include path="./partial/header.mjml" />
|
||||||
|
<mj-body mj-class="bg--blue-100">
|
||||||
|
<mj-wrapper css-class="wrapper" padding="0 40px 40px 40px">
|
||||||
|
<mj-section mj-class="bg--white-100" padding="30px 20px 60px 20px">
|
||||||
|
<mj-column>
|
||||||
|
<mj-text font-size="14px">
|
||||||
|
<p>Cher utilisateur,</p>
|
||||||
|
<p>La réunion <strong>{{room}}</strong> a été transcrite et résumée avec succès.</p>
|
||||||
|
</mj-text>
|
||||||
|
<mj-button href="{{link}}" font-size="14px" background-color="#346DB7" color="white">
|
||||||
|
Obtenez votre résumé
|
||||||
|
</mj-button>
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>
|
||||||
|
</mj-wrapper>
|
||||||
|
</mj-body>
|
||||||
|
<mj-include path="./partial/footer.mjml" />
|
||||||
|
</mjml>
|
||||||
Reference in New Issue
Block a user