Merge pull request #138 from learnhouse/feat/experimental-ai-qa

AI Experimental Features
This commit is contained in:
Badr B 2024-01-28 21:40:25 +01:00 committed by GitHub
commit 0a049e23ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 6953 additions and 315 deletions

View file

@ -8,14 +8,20 @@
"extensions": [
"eamodio.gitlens",
"ms-python.python",
"ms-python.black-formatter",
"ms-python.vscode-pylance",
"styled-components.vscode-styled-components",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"ms-python.isort",
"redhat.vscode-yaml"
]
],
"settings": {
"[python]": {
"editor.defaultFormatter": "ms-python.python"
}
}
}
},
"shutdownAction": "stopCompose"
}
}

View file

@ -1,14 +1,25 @@
#
FROM python:3.11
# poetry
RUN pip install poetry
#
WORKDIR /usr/learnhouse/apps/api
#
COPY ./requirements.txt /usr/learnhouse/requirements.txt
# Copy poetry.lock* in case it doesn't exist in the repo
COPY ./poetry.lock* /usr/learnhouse/
#
RUN pip install --no-cache-dir --upgrade -r /usr/learnhouse/requirements.txt
# Copy project requirement files here to ensure they will be cached.
COPY pyproject.toml /usr/learnhouse/
# Install poetry
RUN pip install --upgrade pip \
&& pip install poetry \
&& poetry config virtualenvs.create false
# Install project dependencies.
RUN poetry install --no-interaction --no-ansi
#
COPY ./ /usr/learnhouse

View file

@ -1,6 +1,7 @@
from typing import Literal, Optional
from pydantic import BaseModel
import os
from dotenv import load_dotenv
import yaml
@ -23,6 +24,11 @@ class SecurityConfig(BaseModel):
auth_jwt_secret_key: str
class AIConfig(BaseModel):
openai_api_key: str | None
is_ai_enabled: bool | None
class S3ApiConfig(BaseModel):
bucket_name: str | None
endpoint_url: str | None
@ -49,6 +55,8 @@ class DatabaseConfig(BaseModel):
sql_connection_string: Optional[str]
mongo_connection_string: Optional[str]
class RedisConfig(BaseModel):
redis_connection_string: Optional[str]
class LearnHouseConfig(BaseModel):
site_name: str
@ -57,10 +65,15 @@ class LearnHouseConfig(BaseModel):
general_config: GeneralConfig
hosting_config: HostingConfig
database_config: DatabaseConfig
redis_config: RedisConfig
security_config: SecurityConfig
ai_config: AIConfig
def get_learnhouse_config() -> LearnHouseConfig:
load_dotenv()
# Get the YAML file
yaml_path = os.path.join(os.path.dirname(__file__), "config.yaml")
@ -173,6 +186,23 @@ def get_learnhouse_config() -> LearnHouseConfig:
"mongo_connection_string"
)
# Redis config
env_redis_connection_string = os.environ.get("LEARNHOUSE_REDIS_CONNECTION_STRING")
redis_connection_string = env_redis_connection_string or yaml_config.get(
"redis_config", {}
).get("redis_connection_string")
# AI Config
env_openai_api_key = os.environ.get("LEARNHOUSE_OPENAI_API_KEY")
env_is_ai_enabled = os.environ.get("LEARNHOUSE_IS_AI_ENABLED")
openai_api_key = env_openai_api_key or yaml_config.get("ai_config", {}).get(
"openai_api_key"
)
is_ai_enabled = env_is_ai_enabled or yaml_config.get("ai_config", {}).get(
"is_ai_enabled"
)
# Sentry config
# check if the sentry config is provided in the YAML file
sentry_config_verif = (
@ -217,6 +247,12 @@ def get_learnhouse_config() -> LearnHouseConfig:
mongo_connection_string=mongo_connection_string,
)
# AI Config
ai_config = AIConfig(
openai_api_key=openai_api_key,
is_ai_enabled=bool(is_ai_enabled),
)
# Create LearnHouseConfig object
config = LearnHouseConfig(
site_name=site_name,
@ -228,6 +264,8 @@ def get_learnhouse_config() -> LearnHouseConfig:
hosting_config=hosting_config,
database_config=database_config,
security_config=SecurityConfig(auth_jwt_secret_key=auth_jwt_secret_key),
ai_config=ai_config,
redis_config=RedisConfig(redis_connection_string=redis_connection_string),
)
return config

View file

@ -27,3 +27,6 @@ hosting_config:
database_config:
sql_connection_string: postgresql://learnhouse:learnhouse@db:5432/learnhouse
mongo_connection_string: mongodb://learnhouse:learnhouse@mongo:27017/
redis_config:
redis_connection_string: redis://redis:6379/learnhouse

4444
apps/api/poetry.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,46 @@
[tool.ruff]
# E501 line too long (82 > 79 characters)
ignore = ["E501"]
ignore = ["E501", "E712"]
[tool.poetry]
name = "learnhouse-api"
version = "0.1.0"
description = "Learnhouse Backend"
authors = ["Badr B. (swve)"]
readme = "README.md"
[tool.poetry.dependencies]
python = "^3.11"
fastapi = "0.104.1"
pydantic = {version = ">=1.8.0,<2.0.0", extras = ["email"]}
sqlmodel = "0.0.10"
uvicorn = "0.23.2"
pymongo = "4.3.3"
motor = "3.1.1"
psycopg2 = "^2.9.9"
python-multipart = "^0.0.6"
boto3 = "^1.34.17"
botocore = "^1.34.17"
python-jose = "^3.3.0"
passlib = "^1.7.4"
fastapi-jwt-auth = "^0.5.0"
pytest = "^7.4.4"
httpx = "^0.26.0"
faker = "^22.2.0"
requests = "^2.31.0"
pyyaml = "^6.0.1"
sentry-sdk = {extras = ["fastapi"], version = "^1.39.2"}
langchain = "0.1.0"
tiktoken = "^0.5.2"
openai = "^1.7.1"
chromadb = "^0.4.22"
sentence-transformers = "^2.2.2"
python-dotenv = "^1.0.0"
redis = "^5.0.1"
langchain-community = "^0.0.11"
langchain-openai = "^0.0.2.post1"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

View file

@ -17,4 +17,13 @@ faker
requests
pyyaml
sentry-sdk[fastapi]
pydantic[email]>=1.8.0,<2.0.0
pydantic[email]>=1.8.0,<2.0.0
langchain==0.1.0
langchain-community
langchain-openai
tiktoken
openai
chromadb
sentence-transformers
python-dotenv
redis

View file

@ -40,7 +40,7 @@ class CourseUpdate(CourseBase):
class CourseRead(CourseBase):
id: int
org_id: int = Field(default=None, foreign_key="organization.id")
authors: List[UserRead]
authors: Optional[List[UserRead]]
course_uuid: str
creation_date: str
update_date: str

View file

@ -0,0 +1,66 @@
from typing import Literal, Optional
from pydantic import BaseModel
from sqlalchemy import JSON, BigInteger, Column, ForeignKey
from sqlmodel import Field, SQLModel
# AI
class AILimitsSettings(BaseModel):
limits_enabled: bool = False
max_asks: int = 0
class AIEnabledFeatures(BaseModel):
editor: bool = False
activity_ask: bool = False
course_ask: bool = False
global_ai_ask: bool = False
class AIConfig(BaseModel):
enabled : bool = True
limits: AILimitsSettings = AILimitsSettings()
embeddings: Literal[
"text-embedding-ada-002", "all-MiniLM-L6-v2"
] = "all-MiniLM-L6-v2"
ai_model: Literal["gpt-3.5-turbo", "gpt-4-1106-preview"] = "gpt-3.5-turbo"
features: AIEnabledFeatures = AIEnabledFeatures()
class OrgUserConfig(BaseModel):
signup_mechanism: Literal["open", "inviteOnly"] = "open"
# Limits
class LimitSettings(BaseModel):
limits_enabled: bool = False
max_users: int = 0
max_storage: int = 0
max_staff: int = 0
# General
class GeneralConfig(BaseModel):
color: str = ""
limits: LimitSettings = LimitSettings()
users: OrgUserConfig = OrgUserConfig()
active: bool = True
class OrganizationConfigBase(SQLModel):
GeneralConfig: GeneralConfig
AIConfig: AIConfig
class OrganizationConfig(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
org_id: int = Field(
sa_column=Column(BigInteger, ForeignKey("organization.id", ondelete="CASCADE"))
)
# TODO: fix this to use the correct type GeneralConfig
config: dict = Field(default={}, sa_column=Column(JSON))
creation_date: Optional[str]
update_date: Optional[str]

View file

@ -1,21 +0,0 @@
from typing import Optional
from sqlalchemy import BigInteger, Column, ForeignKey
from sqlmodel import Field, SQLModel
from enum import Enum
class HeaderTypeEnum(str, Enum):
LOGO_MENU_SETTINGS = "LOGO_MENU_SETTINGS"
MENU_LOGO_SETTINGS = "MENU_LOGO_SETTINGS"
class OrganizationSettings(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
org_id: int = Field(
sa_column=Column(BigInteger, ForeignKey("organization.id", ondelete="CASCADE"))
)
logo_image: Optional[str] = ""
header_type: HeaderTypeEnum = HeaderTypeEnum.LOGO_MENU_SETTINGS
color: str = ""
creation_date: str
update_date: str

View file

@ -1,13 +1,14 @@
from typing import Optional
from sqlmodel import Field, SQLModel
from src.db.organization_config import OrganizationConfig
class OrganizationBase(SQLModel):
name: str
description: Optional[str]
description: Optional[str]
slug: str
email: str
logo_image: Optional[str]
logo_image: Optional[str]
class Organization(OrganizationBase, table=True):
@ -16,9 +17,11 @@ class Organization(OrganizationBase, table=True):
creation_date: str = ""
update_date: str = ""
class OrganizationUpdate(OrganizationBase):
pass
class OrganizationCreate(OrganizationBase):
pass
@ -26,5 +29,6 @@ class OrganizationCreate(OrganizationBase):
class OrganizationRead(OrganizationBase):
id: int
org_uuid: str
creation_date: str
update_date: str
config: Optional[OrganizationConfig | dict]
creation_date: str
update_date: str

View file

@ -1,5 +1,6 @@
from fastapi import APIRouter, Depends
from src.routers import blocks, dev, trail, users, auth, orgs, roles
from src.routers.ai import ai
from src.routers.courses import chapters, collections, courses, activities
from src.routers.install import install
from src.services.dev.dev import isDevModeEnabledOrRaise
@ -18,14 +19,16 @@ v1_router.include_router(blocks.router, prefix="/blocks", tags=["blocks"])
v1_router.include_router(courses.router, prefix="/courses", tags=["courses"])
v1_router.include_router(chapters.router, prefix="/chapters", tags=["chapters"])
v1_router.include_router(activities.router, prefix="/activities", tags=["activities"])
v1_router.include_router(
collections.router, prefix="/collections", tags=["collections"]
)
v1_router.include_router(collections.router, prefix="/collections", tags=["collections"])
v1_router.include_router(trail.router, prefix="/trail", tags=["trail"])
v1_router.include_router(ai.router, prefix="/ai", tags=["ai"])
# Dev Routes
v1_router.include_router(
dev.router, prefix="/dev", tags=["dev"], dependencies=[Depends(isDevModeEnabledOrRaise)]
dev.router,
prefix="/dev",
tags=["dev"],
dependencies=[Depends(isDevModeEnabledOrRaise)],
)
# Install Routes
@ -35,4 +38,3 @@ v1_router.include_router(
tags=["install"],
dependencies=[Depends(isInstallModeEnabled)],
)

View file

@ -0,0 +1,39 @@
from fastapi import APIRouter, Depends, Request
from sqlmodel import Session
from src.services.ai.ai import ai_send_activity_chat_message, ai_start_activity_chat_session
from src.services.ai.schemas.ai import ActivityAIChatSessionResponse, SendActivityAIChatMessage, StartActivityAIChatSession
from src.core.events.database import get_db_session
from src.db.users import PublicUser
from src.security.auth import get_current_user
router = APIRouter()
@router.post("/start/activity_chat_session")
async def api_ai_start_activity_chat_session(
request: Request,
chat_session_object: StartActivityAIChatSession,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
)-> ActivityAIChatSessionResponse:
"""
Start a new AI Chat session with a Course Activity
"""
return ai_start_activity_chat_session(
request, chat_session_object, current_user, db_session
)
@router.post("/send/activity_chat_message")
async def api_ai_send_activity_chat_message(
request: Request,
chat_session_object: SendActivityAIChatMessage,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
)-> ActivityAIChatSessionResponse:
"""
Send a message to an AI Chat session with a Course Activity
"""
return ai_send_activity_chat_message(
request, chat_session_object, current_user, db_session
)

View file

@ -1,6 +1,7 @@
from typing import List
from fastapi import APIRouter, Depends, Request, UploadFile
from sqlmodel import Session
from src.db.organization_config import OrganizationConfigBase
from src.db.users import PublicUser
from src.db.organizations import (
Organization,
@ -12,6 +13,7 @@ from src.core.events.database import get_db_session
from src.security.auth import get_current_user
from src.services.orgs.orgs import (
create_org,
create_org_with_config,
delete_org,
get_organization,
get_organization_by_slug,
@ -37,6 +39,23 @@ async def api_create_org(
return await create_org(request, org_object, current_user, db_session)
# Temporary pre-alpha code
@router.post("/withconfig/")
async def api_create_org_withconfig(
request: Request,
org_object: OrganizationCreate,
config_object: OrganizationConfigBase,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
) -> OrganizationRead:
"""
Create new organization
"""
return await create_org_with_config(
request, org_object, current_user, db_session, config_object
)
@router.get("/{org_id}")
async def api_get_org(
request: Request,
@ -110,7 +129,7 @@ async def api_update_org(
"""
Update Org by ID
"""
return await update_org(request, org_object,org_id, current_user, db_session)
return await update_org(request, org_object, org_id, current_user, db_session)
@router.delete("/{org_id}")

View file

@ -0,0 +1,204 @@
from fastapi import Depends, HTTPException, Request
from sqlmodel import Session, select
from src.db.organization_config import OrganizationConfig
from src.db.organizations import Organization
from src.services.ai.utils import check_limits_and_config, count_ai_ask
from src.db.courses import Course, CourseRead
from src.core.events.database import get_db_session
from src.db.users import PublicUser
from src.db.activities import Activity, ActivityRead
from src.security.auth import get_current_user
from src.services.ai.base import ask_ai, get_chat_session_history
from src.services.ai.schemas.ai import (
ActivityAIChatSessionResponse,
SendActivityAIChatMessage,
StartActivityAIChatSession,
)
from src.services.courses.activities.utils import (
serialize_activity_text_to_ai_comprehensible_text,
structure_activity_content_by_type,
)
def ai_start_activity_chat_session(
request: Request,
chat_session_object: StartActivityAIChatSession,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
) -> ActivityAIChatSessionResponse:
"""
Start a new AI Chat session with a Course Activity
"""
# Get the Activity
statement = select(Activity).where(
Activity.activity_uuid == chat_session_object.activity_uuid
)
activity = db_session.exec(statement).first()
activity = ActivityRead.from_orm(activity)
# Get the Course
statement = (
select(Course)
.join(Activity)
.where(Activity.activity_uuid == chat_session_object.activity_uuid)
)
course = db_session.exec(statement).first()
course = CourseRead.from_orm(course)
# Get the Organization
statement = select(Organization).where(Organization.id == course.org_id)
org = db_session.exec(statement).first()
# Check limits and usage
check_limits_and_config(db_session, org) # type: ignore
count_ai_ask(org, "increment") # type: ignore
if not activity:
raise HTTPException(
status_code=404,
detail="Activity not found",
)
# Get Activity Content Blocks
content = activity.content
# Serialize Activity Content Blocks to a text comprehensible by the AI
structured = structure_activity_content_by_type(content)
ai_friendly_text = serialize_activity_text_to_ai_comprehensible_text(
structured, course, activity
)
# Get Activity Organization
statement = select(Organization).where(Organization.id == course.org_id)
org = db_session.exec(statement).first()
# Get Organization Config
statement = select(OrganizationConfig).where(
OrganizationConfig.org_id == org.id # type: ignore
)
result = db_session.exec(statement)
org_config = result.first()
org_config = OrganizationConfig.from_orm(org_config)
embeddings = org_config.config["AIConfig"]["embeddings"]
ai_model = org_config.config["AIConfig"]["ai_model"]
chat_session = get_chat_session_history()
message = "You are a helpful Education Assistant, and you are helping a student with the associated Course. "
message += "Use the available tools to get context about this question even if the question is not specific enough."
message += "For context, this is the Course name :"
message += course.name
message += " and this is the Lecture name :"
message += activity.name
message += "."
message += "Use your knowledge to help the student if the context is not enough."
response = ask_ai(
chat_session_object.message,
chat_session["message_history"],
ai_friendly_text,
message,
embeddings,
ai_model,
)
return ActivityAIChatSessionResponse(
aichat_uuid=chat_session["aichat_uuid"],
activity_uuid=activity.activity_uuid,
message=response["output"],
)
def ai_send_activity_chat_message(
request: Request,
chat_session_object: SendActivityAIChatMessage,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
) -> ActivityAIChatSessionResponse:
"""
Start a new AI Chat session with a Course Activity
"""
# Get the Activity
statement = select(Activity).where(
Activity.activity_uuid == chat_session_object.activity_uuid
)
activity = db_session.exec(statement).first()
activity = ActivityRead.from_orm(activity)
# Get the Course
statement = (
select(Course)
.join(Activity)
.where(Activity.activity_uuid == chat_session_object.activity_uuid)
)
course = db_session.exec(statement).first()
course = CourseRead.from_orm(course)
# Get the Organization
statement = select(Organization).where(Organization.id == course.org_id)
org = db_session.exec(statement).first()
# Check limits and usage
check_limits_and_config(db_session, org) # type: ignore
count_ai_ask(org, "increment") # type: ignore
if not activity:
raise HTTPException(
status_code=404,
detail="Activity not found",
)
# Get Activity Content Blocks
content = activity.content
# Serialize Activity Content Blocks to a text comprehensible by the AI
structured = structure_activity_content_by_type(content)
ai_friendly_text = serialize_activity_text_to_ai_comprehensible_text(
structured, course, activity
)
# Get Activity Organization
statement = select(Organization).where(Organization.id == course.org_id)
org = db_session.exec(statement).first()
# Get Organization Config
statement = select(OrganizationConfig).where(
OrganizationConfig.org_id == org.id # type: ignore
)
result = db_session.exec(statement)
org_config = result.first()
org_config = OrganizationConfig.from_orm(org_config)
embeddings = org_config.config["AIConfig"]["embeddings"]
ai_model = org_config.config["AIConfig"]["ai_model"]
chat_session = get_chat_session_history(chat_session_object.aichat_uuid)
message = "You are a helpful Education Assistant, and you are helping a student with the associated Course. "
message += "Use the available tools to get context about this question even if the question is not specific enough."
message += "For context, this is the Course name :"
message += course.name
message += " and this is the Lecture name :"
message += activity.name
message += "."
message += "Use your knowledge to help the student if the context is not enough."
response = ask_ai(
chat_session_object.message,
chat_session["message_history"],
ai_friendly_text,
message,
embeddings,
ai_model,
)
return ActivityAIChatSessionResponse(
aichat_uuid=chat_session["aichat_uuid"],
activity_uuid=activity.activity_uuid,
message=response["output"],
)

View file

@ -0,0 +1,117 @@
from typing import Optional
from uuid import uuid4
from langchain.agents import AgentExecutor
from langchain.text_splitter import CharacterTextSplitter
from langchain.embeddings.sentence_transformer import SentenceTransformerEmbeddings
from langchain_community.vectorstores import Chroma
from langchain.agents.openai_functions_agent.base import OpenAIFunctionsAgent
from langchain.prompts import MessagesPlaceholder
from langchain_community.chat_message_histories import RedisChatMessageHistory
from langchain_core.messages import SystemMessage
from langchain.agents.openai_functions_agent.agent_token_buffer_memory import (
AgentTokenBufferMemory,
)
from langchain_openai import OpenAIEmbeddings
from langchain_community.chat_models import ChatOpenAI
from langchain.agents.agent_toolkits import (
create_retriever_tool,
)
import chromadb
from config.config import get_learnhouse_config
client = chromadb.Client()
chat_history = []
def ask_ai(
question: str,
message_history,
text_reference: str,
message_for_the_prompt: str,
embedding_model_name: str,
openai_model_name: str,
):
# Get API Keys
LH_CONFIG = get_learnhouse_config()
openai_api_key = LH_CONFIG.ai_config.openai_api_key
# split it into chunks
text_splitter = CharacterTextSplitter(chunk_size=1000, chunk_overlap=0)
documents = text_splitter.create_documents([text_reference])
texts = text_splitter.split_documents(documents)
embedding_models = {
"all-MiniLM-L6-v2": SentenceTransformerEmbeddings,
"text-embedding-ada-002": OpenAIEmbeddings,
}
embedding_function = None
if embedding_model_name in embedding_models:
if embedding_model_name == "text-embedding-ada-002":
embedding_function = embedding_models[embedding_model_name](model=embedding_model_name, api_key=openai_api_key)
if embedding_model_name == "all-MiniLM-L6-v2":
embedding_function = embedding_models[embedding_model_name](model_name=embedding_model_name)
else:
embedding_function = embedding_models[embedding_model_name](model_name=embedding_model_name)
# load it into Chroma and use it as a retriever
db = Chroma.from_documents(texts, embedding_function)
tool = create_retriever_tool(
db.as_retriever(),
"find_context_text",
"Find associated text to get context about a course or a lecture",
)
tools = [tool]
llm = ChatOpenAI(
temperature=0, api_key=openai_api_key, model_name=openai_model_name
)
memory_key = "history"
memory = AgentTokenBufferMemory(
memory_key=memory_key, llm=llm, chat_memory=message_history, max_token_limit=1000
)
system_message = SystemMessage(content=(message_for_the_prompt))
prompt = OpenAIFunctionsAgent.create_prompt(
system_message=system_message,
extra_prompt_messages=[MessagesPlaceholder(variable_name=memory_key)],
)
agent = OpenAIFunctionsAgent(llm=llm, tools=tools, prompt=prompt)
agent_executor = AgentExecutor(
agent=agent,
tools=tools,
memory=memory,
verbose=True,
return_intermediate_steps=True,
handle_parsing_errors=True,
)
return agent_executor({"input": question})
def get_chat_session_history(aichat_uuid: Optional[str] = None):
# Init Message History
session_id = aichat_uuid if aichat_uuid else f"aichat_{uuid4()}"
LH_CONFIG = get_learnhouse_config()
redis_conn_string = LH_CONFIG.redis_config.redis_connection_string
if redis_conn_string:
message_history = RedisChatMessageHistory(
url=redis_conn_string, ttl=2160000, session_id=session_id
)
else:
print("Redis connection string not found, using local memory")
message_history = []
return {"message_history": message_history, "aichat_uuid": session_id}

View file

@ -0,0 +1,17 @@
from pydantic import BaseModel
class StartActivityAIChatSession(BaseModel):
activity_uuid: str
message: str
class ActivityAIChatSessionResponse(BaseModel):
aichat_uuid: str
activity_uuid: str
message: str
class SendActivityAIChatMessage(BaseModel):
aichat_uuid: str
activity_uuid: str
message: str

View file

@ -0,0 +1,114 @@
from typing import Literal
import redis
from fastapi import HTTPException
from sqlmodel import Session, select
from config.config import get_learnhouse_config
from src.db.organization_config import OrganizationConfig
from src.db.organizations import Organization
def count_ai_ask(
organization: Organization,
operation: Literal["increment", "decrement"],
):
"""
Count the number of AI asks
"""
LH_CONFIG = get_learnhouse_config()
redis_conn_string = LH_CONFIG.redis_config.redis_connection_string
if not redis_conn_string:
raise HTTPException(
status_code=500,
detail="Redis connection string not found",
)
# Connect to Redis
r = redis.Redis.from_url(redis_conn_string)
if not r:
raise HTTPException(
status_code=500,
detail="Could not connect to Redis",
)
# Get the number of AI asks
ai_asks = r.get(f"ai_asks:{organization.org_uuid}")
if ai_asks is None:
ai_asks = 0
# Increment or decrement the number of AI asks
if operation == "increment":
ai_asks = int(ai_asks) + 1
elif operation == "decrement":
ai_asks = int(ai_asks) - 1
# Update the number of AI asks
r.set(f"ai_asks:{organization.org_uuid}", ai_asks)
# Set the expiration time to 30 days
r.expire(f"ai_asks:{organization.org_uuid}", 2592000)
def check_limits_and_config(db_session: Session, organization: Organization):
"""
Check the limits and config of an Organization
"""
# Get the Organization Config
statement = select(OrganizationConfig).where(
OrganizationConfig.org_id == organization.id
)
result = db_session.exec(statement)
org_config = result.first()
if org_config is None:
raise HTTPException(
status_code=404,
detail="Organization has no config",
)
# Check if the Organizations has AI enabled
if org_config.config["AIConfig"]["enabled"] == False:
raise HTTPException(
status_code=403,
detail="Organization has AI disabled",
)
# Check if the Organization has Limits enabled and if the max_asks limit has been reached
if org_config.config["AIConfig"]["limits"]["limits_enabled"] == True:
LH_CONFIG = get_learnhouse_config()
redis_conn_string = LH_CONFIG.redis_config.redis_connection_string
if not redis_conn_string:
raise HTTPException(
status_code=500,
detail="Redis connection string not found",
)
# Connect to Redis
r = redis.Redis.from_url(redis_conn_string)
if not r:
raise HTTPException(
status_code=500,
detail="Could not connect to Redis",
)
# Get the number of AI asks
ai_asks = r.get(f"ai_asks:{organization.org_uuid}")
# Get a number of AI asks
if ai_asks is None:
ai_asks = 0
else:
ai_asks = int(ai_asks)
# Check if the Number of asks is less than the max_asks limit
if org_config.config["AIConfig"]["limits"]["max_asks"] <= ai_asks:
raise HTTPException(
status_code=403,
detail="Organization has reached the max number of AI asks",
)

View file

@ -44,12 +44,12 @@ async def create_image_block(
image_file,
activity_uuid,
block_uuid,
["jpg", "jpeg", "png", "gif"],
["jpg", "jpeg", "png", "gif", "webp"],
block_type,
org.org_uuid,
str(course.course_uuid),
)
# create block
block = Block(
activity_id=activity.id if activity.id else 0,

View file

@ -0,0 +1,81 @@
from src.db.activities import ActivityRead
from src.db.courses import CourseRead
def structure_activity_content_by_type(activity):
### Get Headings, Texts, Callouts, Answers and Paragraphs from the activity as a big list of strings (text only) and return it
content = activity["content"]
headings = []
callouts = []
paragraphs = []
for item in content:
if 'content' in item:
if item["type"] == "heading" and "text" in item["content"][0]:
headings.append(item["content"][0]["text"])
elif item["type"] in ["calloutInfo", "calloutWarning"] and all("text" in text_item for text_item in item["content"]):
callouts.append(
"".join([text_item["text"] for text_item in item["content"]])
)
elif item["type"] == "paragraph" and "text" in item["content"][0]:
paragraphs.append(item["content"][0]["text"])
# TODO: Get Questions and Answers (if any)
data_array = []
# Add Headings
data_array.append({"Headings": headings})
# Add Callouts
data_array.append({"Callouts": callouts})
# Add Paragraphs
data_array.append({"Paragraphs": paragraphs})
print(data_array)
return data_array
def serialize_activity_text_to_ai_comprehensible_text(
data_array, course: CourseRead, activity: ActivityRead
):
### Serialize the text to a format that is comprehensible by the AI
# Serialize Headings
serialized_headings = ""
for heading in data_array[0]["Headings"]:
serialized_headings += heading + " "
# Serialize Callouts
serialized_callouts = ""
for callout in data_array[1]["Callouts"]:
serialized_callouts += callout + " "
# Serialize Paragraphs
serialized_paragraphs = ""
for paragraph in data_array[2]["Paragraphs"]:
serialized_paragraphs += paragraph + " "
# Get a text that is comprehensible by the AI
text = (
"Use this as a context "
+ 'This is a course about "'
+ course.name
+ '". '
+ 'This is a lecture about "'
+ activity.name
+ '". '
'These are the headings: "'
+ serialized_headings
+ '" These are the callouts: "'
+ serialized_callouts
+ '" These are the paragraphs: "'
+ serialized_paragraphs
+ '"'
)
return text

View file

@ -1,7 +1,19 @@
import json
import logging
from datetime import datetime
from typing import Literal
from uuid import uuid4
from sqlmodel import Session, select
from src.db.organization_config import (
AIConfig,
AIEnabledFeatures,
AILimitsSettings,
GeneralConfig,
LimitSettings,
OrgUserConfig,
OrganizationConfig,
OrganizationConfigBase,
)
from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship,
authorization_verify_if_user_is_anon,
@ -23,7 +35,7 @@ async def get_organization(
org_id: str,
db_session: Session,
current_user: PublicUser | AnonymousUser,
):
) -> OrganizationRead:
statement = select(Organization).where(Organization.id == org_id)
result = db_session.exec(statement)
@ -38,7 +50,18 @@ async def get_organization(
# RBAC check
await rbac_check(request, org.org_uuid, current_user, "read", db_session)
org = OrganizationRead.from_orm(org)
# Get org config
statement = select(OrganizationConfig).where(OrganizationConfig.org_id == org.id)
result = db_session.exec(statement)
org_config = result.first()
if org_config is None:
logging.error(f"Organization {org_id} has no config")
config = OrganizationConfig.from_orm(org_config) if org_config else {}
org = OrganizationRead(**org.dict(), config=config)
return org
@ -48,7 +71,7 @@ async def get_organization_by_slug(
org_slug: str,
db_session: Session,
current_user: PublicUser | AnonymousUser,
):
) -> OrganizationRead:
statement = select(Organization).where(Organization.slug == org_slug)
result = db_session.exec(statement)
@ -63,7 +86,18 @@ async def get_organization_by_slug(
# RBAC check
await rbac_check(request, org.org_uuid, current_user, "read", db_session)
org = OrganizationRead.from_orm(org)
# Get org config
statement = select(OrganizationConfig).where(OrganizationConfig.org_id == org.id)
result = db_session.exec(statement)
org_config = result.first()
if org_config is None:
logging.error(f"Organization {org_slug} has no config")
config = OrganizationConfig.from_orm(org_config) if org_config else {}
org = OrganizationRead(**org.dict(), config=config)
return org
@ -87,7 +121,7 @@ async def create_org(
org = Organization.from_orm(org_object)
if isinstance(current_user,AnonymousUser):
if isinstance(current_user, AnonymousUser):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="You should be logged in to be able to achieve this action",
@ -115,7 +149,146 @@ async def create_org(
db_session.commit()
db_session.refresh(user_org)
return OrganizationRead.from_orm(org)
org_config = OrganizationConfigBase(
GeneralConfig=GeneralConfig(
color="#000000",
limits=LimitSettings(
limits_enabled=False,
max_users=0,
max_storage=0,
max_staff=0,
),
users=OrgUserConfig(
signup_mechanism="open",
),
active=True,
),
AIConfig=AIConfig(
enabled=False,
limits=AILimitsSettings(
limits_enabled=False,
max_asks=0,
),
embeddings="all-MiniLM-L6-v2",
ai_model="gpt-3.5-turbo",
features=AIEnabledFeatures(
editor=False,
activity_ask=False,
course_ask=False,
global_ai_ask=False,
),
),
)
org_config = json.loads(org_config.json())
# OrgSettings
org_settings = OrganizationConfig(
org_id=int(org.id if org.id else 0),
config=org_config,
creation_date=str(datetime.now()),
update_date=str(datetime.now()),
)
db_session.add(org_settings)
db_session.commit()
db_session.refresh(org_settings)
# Get org config
statement = select(OrganizationConfig).where(OrganizationConfig.org_id == org.id)
result = db_session.exec(statement)
org_config = result.first()
if org_config is None:
logging.error(f"Organization {org.id} has no config")
config = OrganizationConfig.from_orm(org_config)
org = OrganizationRead(**org.dict(), config=config)
return org
# Temporary pre-alpha code
async def create_org_with_config(
request: Request,
org_object: OrganizationCreate,
current_user: PublicUser | AnonymousUser,
db_session: Session,
submitted_config: OrganizationConfigBase,
):
statement = select(Organization).where(Organization.slug == org_object.slug)
result = db_session.exec(statement)
org = result.first()
if org:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Organization already exists",
)
org = Organization.from_orm(org_object)
if isinstance(current_user, AnonymousUser):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="You should be logged in to be able to achieve this action",
)
# Complete the org object
org.org_uuid = f"org_{uuid4()}"
org.creation_date = str(datetime.now())
org.update_date = str(datetime.now())
db_session.add(org)
db_session.commit()
db_session.refresh(org)
# Link user to org
user_org = UserOrganization(
user_id=int(current_user.id),
org_id=int(org.id if org.id else 0),
role_id=1,
creation_date=str(datetime.now()),
update_date=str(datetime.now()),
)
db_session.add(user_org)
db_session.commit()
db_session.refresh(user_org)
org_config = submitted_config
org_config = json.loads(org_config.json())
# OrgSettings
org_settings = OrganizationConfig(
org_id=int(org.id if org.id else 0),
config=org_config,
creation_date=str(datetime.now()),
update_date=str(datetime.now()),
)
db_session.add(org_settings)
db_session.commit()
db_session.refresh(org_settings)
# Get org config
statement = select(OrganizationConfig).where(OrganizationConfig.org_id == org.id)
result = db_session.exec(statement)
org_config = result.first()
if org_config is None:
logging.error(f"Organization {org.id} has no config")
config = OrganizationConfig.from_orm(org_config)
org = OrganizationRead(**org.dict(), config=config)
return org
async def update_org(

View file

@ -7,7 +7,7 @@ from src.db.roles import Role, RoleRead
from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship,
authorization_verify_if_user_is_anon,
)
)
from src.db.organizations import Organization, OrganizationRead
from src.db.users import (
AnonymousUser,

View file

@ -7,6 +7,9 @@ import { getActivityWithAuthHeader } from "@services/courses/activities";
import { getAccessTokenFromRefreshTokenCookie, getNewAccessTokenUsingRefreshTokenServer } from "@services/auth/auth";
import { getOrganizationContextInfo, getOrganizationContextInfoWithId } from "@services/organizations/orgs";
import SessionProvider from "@components/Contexts/SessionContext";
import EditorOptionsProvider from "@components/Contexts/Editor/EditorContext";
import AIChatBotProvider from "@components/Contexts/AI/AIChatBotContext";
import AIEditorProvider from "@components/Contexts/AI/AIEditorContext";
type MetadataProps = {
params: { orgslug: string, courseid: string, activityid: string };
@ -35,14 +38,15 @@ const EditActivity = async (params: any) => {
const courseInfo = await getCourseMetadataWithAuthHeader(courseid, { revalidate: 0, tags: ['courses'] }, access_token ? access_token : null)
const activity = await getActivityWithAuthHeader(activityuuid, { revalidate: 0, tags: ['activities'] }, access_token ? access_token : null)
const org = await getOrganizationContextInfoWithId(courseInfo.org_id, { revalidate: 1800, tags: ['organizations'] });
console.log('courseInfo', courseInfo)
return (
<div>
<SessionProvider>
<EditorWrapper org={org} course={courseInfo} activity={activity} content={activity.content}></EditorWrapper>
</SessionProvider>
</div>
<EditorOptionsProvider options={{ isEditable: true }}>
<AIEditorProvider>
<SessionProvider>
<EditorWrapper org={org} course={courseInfo} activity={activity} content={activity.content}></EditorWrapper>
</SessionProvider>
</AIEditorProvider>
</EditorOptionsProvider>
);
}

View file

@ -1,6 +1,7 @@
"use client";
import "../styles/globals.css";
import StyledComponentsRegistry from "../components/Utils/libs/styled-registry";
import { motion } from "framer-motion";
export default function RootLayout({ children }: { children: React.ReactNode }) {

View file

@ -3,7 +3,7 @@ import Link from "next/link";
import { getUriWithOrg } from "@services/config/config";
import Canva from "@components/Objects/Activities/DynamicCanva/DynamicCanva";
import VideoActivity from "@components/Objects/Activities/Video/Video";
import { Check } from "lucide-react";
import { Check, MoreVertical } from "lucide-react";
import { markActivityAsComplete } from "@services/courses/activity";
import DocumentPdfActivity from "@components/Objects/Activities/DocumentPdf/DocumentPdf";
import ActivityIndicators from "@components/Pages/Courses/ActivityIndicators";
@ -13,6 +13,8 @@ import AuthenticatedClientElement from "@components/Security/AuthenticatedClient
import { getCourseThumbnailMediaDirectory } from "@services/media/media";
import { useOrg } from "@components/Contexts/OrgContext";
import { CourseProvider } from "@components/Contexts/CourseContext";
import AIActivityAsk from "@components/Objects/Activities/AI/AIActivityAsk";
import AIChatBotProvider from "@components/Contexts/AI/AIChatBotContext";
interface ActivityClientProps {
activityid: string;
@ -31,14 +33,17 @@ function ActivityClient(props: ActivityClientProps) {
const course = props.course;
const org = useOrg() as any;
function getChapterName(chapterId: string) {
let chapterName = "";
course.chapters.forEach((chapter: any) => {
if (chapter.id === chapterId) {
chapterName = chapter.name;
function getChapterNameByActivityId(course: any, activity_id: any) {
for (let i = 0; i < course.chapters.length; i++) {
let chapter = course.chapters[i];
for (let j = 0; j < chapter.activities.length; j++) {
let activity = chapter.activities[j];
if (activity.id === activity_id) {
return chapter.name;
}
}
});
return chapterName;
}
return null; // return null if no matching activity is found
}
@ -46,47 +51,50 @@ function ActivityClient(props: ActivityClientProps) {
return (
<>
<CourseProvider courseuuid={course?.course_uuid}>
<GeneralWrapperStyled>
<div className="space-y-4 pt-4">
<div className="flex space-x-6">
<div className="flex">
<Link href={getUriWithOrg(orgslug, "") + `/course/${courseuuid}`}>
<img className="w-[100px] h-[57px] rounded-md drop-shadow-md" src={`${getCourseThumbnailMediaDirectory(org?.org_uuid, course.course_uuid, course.thumbnail_image)}`} alt="" />
</Link>
</div>
<div className="flex flex-col -space-y-1">
<p className="font-bold text-gray-700 text-md">Course </p>
<h1 className="font-bold text-gray-950 text-2xl first-letter:uppercase" >{course.name}</h1>
</div>
</div>
<ActivityIndicators course_uuid={courseuuid} current_activity={activityid} orgslug={orgslug} course={course} />
<div className="flex justify-between items-center">
<div className="flex flex-col -space-y-1">
<p className="font-bold text-gray-700 text-md">Chapter : {getChapterName(activity.coursechapter_id)}</p>
<h1 className="font-bold text-gray-950 text-2xl first-letter:uppercase" >{activity.name}</h1>
</div>
<div className="flex space-x-2">
<AuthenticatedClientElement checkMethod="authentication">
<MarkStatus activity={activity} activityid={activityid} course={course} orgslug={orgslug} />
</AuthenticatedClientElement>
</div>
</div>
{activity ? (
<div className={`p-7 pt-4 drop-shadow-sm rounded-lg ${activity.activity_type == 'TYPE_DYNAMIC' ? 'bg-white' : 'bg-zinc-950'}`}>
<div>
{activity.activity_type == "TYPE_DYNAMIC" && <Canva content={activity.content} activity={activity} />}
{/* todo : use apis & streams instead of this */}
{activity.activity_type == "TYPE_VIDEO" && <VideoActivity course={course} activity={activity} />}
{activity.activity_type == "TYPE_DOCUMENT" && <DocumentPdfActivity course={course} activity={activity} />}
<AIChatBotProvider>
<GeneralWrapperStyled>
<div className="space-y-4 pt-4">
<div className="flex space-x-6">
<div className="flex">
<Link href={getUriWithOrg(orgslug, "") + `/course/${courseuuid}`}>
<img className="w-[100px] h-[57px] rounded-md drop-shadow-md" src={`${getCourseThumbnailMediaDirectory(org?.org_uuid, course.course_uuid, course.thumbnail_image)}`} alt="" />
</Link>
</div>
<div className="flex flex-col -space-y-1">
<p className="font-bold text-gray-700 text-md">Course </p>
<h1 className="font-bold text-gray-950 text-2xl first-letter:uppercase" >{course.name}</h1>
</div>
</div>
) : (<div></div>)}
{<div style={{ height: "100px" }}></div>}
</div>
</GeneralWrapperStyled>
<ActivityIndicators course_uuid={courseuuid} current_activity={activityid} orgslug={orgslug} course={course} />
<div className="flex justify-between items-center">
<div className="flex flex-col -space-y-1">
<p className="font-bold text-gray-700 text-md">Chapter : {getChapterNameByActivityId(course, activity.id)}</p>
<h1 className="font-bold text-gray-950 text-2xl first-letter:uppercase" >{activity.name}</h1>
</div>
<div className="flex space-x-1 items-center">
<AuthenticatedClientElement checkMethod="authentication">
<AIActivityAsk activity={activity} />
<MoreVertical size={17} className="text-gray-300 " />
<MarkStatus activity={activity} activityid={activityid} course={course} orgslug={orgslug} />
</AuthenticatedClientElement>
</div>
</div>
{activity ? (
<div className={`p-7 pt-4 drop-shadow-sm rounded-lg ${activity.activity_type == 'TYPE_DYNAMIC' ? 'bg-white' : 'bg-zinc-950'}`}>
<div>
{activity.activity_type == "TYPE_DYNAMIC" && <Canva content={activity.content} activity={activity} />}
{/* todo : use apis & streams instead of this */}
{activity.activity_type == "TYPE_VIDEO" && <VideoActivity course={course} activity={activity} />}
{activity.activity_type == "TYPE_DOCUMENT" && <DocumentPdfActivity course={course} activity={activity} />}
</div>
</div>
) : (<div></div>)}
{<div style={{ height: "100px" }}></div>}
</div>
</GeneralWrapperStyled>
</AIChatBotProvider>
</CourseProvider>
</>
);
@ -114,19 +122,19 @@ export function MarkStatus(props: { activity: any, activityid: string, course: a
return (
<>{isActivityCompleted() ? (
<div className="bg-teal-600 rounded-md drop-shadow-md flex flex-col p-3 text-sm text-white hover:cursor-pointer transition delay-150 duration-300 ease-in-out" >
<div className="bg-teal-600 rounded-full px-5 drop-shadow-md flex items-center space-x-1 p-2.5 text-white hover:cursor-pointer transition delay-150 duration-300 ease-in-out" >
<i>
<Check size={15}></Check>
<Check size={17}></Check>
</i>{" "}
Already completed
<i className="not-italic text-xs font-bold">Already completed</i>
</div>
) : (
<div className="bg-zinc-600 rounded-md drop-shadow-md flex flex-col p-3 text-sm text-white hover:cursor-pointer transition delay-150 duration-300 ease-in-out" onClick={markActivityAsCompleteFront}>
<div className="bg-gray-800 rounded-full px-5 drop-shadow-md flex items-center space-x-1 p-2.5 text-white hover:cursor-pointer transition delay-150 duration-300 ease-in-out" onClick={markActivityAsCompleteFront}>
{" "}
<i>
<Check size={15}></Check>
<Check size={17}></Check>
</i>{" "}
Mark as complete
<i className="not-italic text-xs font-bold">Mark as complete</i>
</div>
)}</>
)

View file

@ -0,0 +1,45 @@
import { useOrg } from '@components/Contexts/OrgContext'
import React from 'react'
interface UseGetAIFeatures {
feature: 'editor' | 'activity_ask' | 'course_ask' | 'global_ai_ask',
}
function useGetAIFeatures(props: UseGetAIFeatures) {
const org = useOrg() as any
const [isEnabled, setisEnabled] = React.useState(false)
function checkAvailableAIFeaturesOnOrg(feature: string) {
const config = org?.config?.config?.AIConfig;
if (!config) {
console.log("AI or Organization config is not defined.");
return false;
}
if (!config.enabled) {
console.log("AI is not enabled for this Organization.");
return false;
}
if (!config.features[feature]) {
console.log(`Feature ${feature} is not enabled for this Organization.`);
return false;
}
return true;
}
React.useEffect(() => {
if (org) { // Check if org is not null or undefined
let isEnabledStatus = checkAvailableAIFeaturesOnOrg(props.feature)
setisEnabled(isEnabledStatus)
}
}, [org])
return isEnabled
}
export default useGetAIFeatures

View file

@ -0,0 +1,76 @@
'use client';
import { AIMessage } from '@components/Objects/Activities/AI/AIActivityAsk';
import React, { createContext, useContext, useReducer } from 'react'
export const AIChatBotContext = createContext(null) as any;
export const AIChatBotDispatchContext = createContext(null) as any;
export type AIChatBotStateTypes = {
messages: AIMessage[],
isModalOpen: boolean,
aichat_uuid: string,
isWaitingForResponse: boolean,
chatInputValue: string
error: AIError
}
type AIError = {
isError: boolean
status: number
error_message: string
}
function AIChatBotProvider({ children }: { children: React.ReactNode }) {
const [aiChatBotState, dispatchAIChatBot] = useReducer(aiChatBotReducer,
{
messages: [] as AIMessage[],
isModalOpen: false,
aichat_uuid: null,
isWaitingForResponse: false,
chatInputValue: '',
error: { isError: false, status: 0, error_message: ' ' } as AIError
}
);
return (
<AIChatBotContext.Provider value={aiChatBotState}>
<AIChatBotDispatchContext.Provider value={dispatchAIChatBot}>
{children}
</AIChatBotDispatchContext.Provider>
</AIChatBotContext.Provider>
)
}
export default AIChatBotProvider
export function useAIChatBot() {
return useContext(AIChatBotContext);
}
export function useAIChatBotDispatch() {
return useContext(AIChatBotDispatchContext);
}
function aiChatBotReducer(state: any, action: any) {
switch (action.type) {
case 'setMessages':
return { ...state, messages: action.payload };
case 'addMessage':
return { ...state, messages: [...state.messages, action.payload] };
case 'setIsModalOpen':
return { ...state, isModalOpen: true };
case 'setIsModalClose':
return { ...state, isModalOpen: false };
case 'setAichat_uuid':
return { ...state, aichat_uuid: action.payload };
case 'setIsWaitingForResponse':
return { ...state, isWaitingForResponse: true };
case 'setIsNoLongerWaitingForResponse':
return { ...state, isWaitingForResponse: false };
case 'setChatInputValue':
return { ...state, chatInputValue: action.payload };
case 'setError':
return { ...state, error: action.payload };
default:
throw new Error(`Unhandled action type: ${action.type}`)
}
}

View file

@ -0,0 +1,92 @@
'use client';
import { AIMessage } from '@components/Objects/Activities/AI/AIActivityAsk';
import React, { createContext, useContext, useReducer } from 'react'
export const AIEditorContext = createContext(null) as any;
export const AIEditorDispatchContext = createContext(null) as any;
export type AIEditorStateTypes = {
messages: AIMessage[],
isModalOpen: boolean,
isFeedbackModalOpen: boolean,
aichat_uuid: string,
isWaitingForResponse: boolean,
chatInputValue: string,
selectedTool: 'Writer' | 'ContinueWriting' | 'MakeLonger' | 'GenerateQuiz' | 'Translate'
isUserInputEnabled: boolean
error: AIError
}
type AIError = {
isError: boolean
status: number
error_message: string
}
function AIEditorProvider({ children }: { children: React.ReactNode }) {
const [aIEditorState, dispatchAIEditor] = useReducer(aIEditorReducer,
{
messages: [] as AIMessage[],
isModalOpen: false,
isFeedbackModalOpen: false,
aichat_uuid: null,
isWaitingForResponse: false,
chatInputValue: '',
selectedTool: 'Writer',
isUserInputEnabled: true,
error: { isError: false, status: 0, error_message: ' ' } as AIError
}
);
return (
<AIEditorContext.Provider value={aIEditorState}>
<AIEditorDispatchContext.Provider value={dispatchAIEditor}>
{children}
</AIEditorDispatchContext.Provider>
</AIEditorContext.Provider>
)
}
export default AIEditorProvider
export function useAIEditor() {
return useContext(AIEditorContext);
}
export function useAIEditorDispatch() {
return useContext(AIEditorDispatchContext);
}
function aIEditorReducer(state: any, action: any) {
switch (action.type) {
case 'setMessages':
return { ...state, messages: action.payload };
case 'addMessage':
return { ...state, messages: [...state.messages, action.payload] };
case 'setIsModalOpen':
return { ...state, isModalOpen: true };
case 'setIsModalClose':
return { ...state, isModalOpen: false };
case 'setAichat_uuid':
return { ...state, aichat_uuid: action.payload };
case 'setIsWaitingForResponse':
return { ...state, isWaitingForResponse: true };
case 'setIsNoLongerWaitingForResponse':
return { ...state, isWaitingForResponse: false };
case 'setChatInputValue':
return { ...state, chatInputValue: action.payload };
case 'setSelectedTool':
return { ...state, selectedTool: action.payload };
case 'setIsFeedbackModalOpen':
return { ...state, isFeedbackModalOpen: true };
case 'setIsFeedbackModalClose':
return { ...state, isFeedbackModalOpen: false };
case 'setIsUserInputEnabled':
return { ...state, isUserInputEnabled: action.payload };
case 'setError':
return { ...state, error: action.payload };
default:
throw new Error(`Unhandled action type: ${action.type}`)
}
}

View file

@ -0,0 +1,32 @@
'use client';
import React, { useState } from 'react'
export const EditorProviderContext = React.createContext(null) as any;
type EditorProviderProps = {
children: React.ReactNode
options: EditorProviderState
}
type EditorProviderState = {
isEditable: boolean
}
function EditorOptionsProvider({ children, options }: EditorProviderProps) {
const [editorOptions, setEditorOptions] = useState<EditorProviderState>(options);
return (
<EditorProviderContext.Provider value={editorOptions}>
{children}
</EditorProviderContext.Provider>
)
}
export default EditorOptionsProvider
export function useEditorProvider() {
return React.useContext(EditorProviderContext);
}

View file

@ -0,0 +1,325 @@
import { useSession } from '@components/Contexts/SessionContext'
import { sendActivityAIChatMessage, startActivityAIChatSession } from '@services/ai/ai';
import { AlertTriangle, BadgeInfo, NotebookTabs } from 'lucide-react';
import Avvvatars from 'avvvatars-react';
import { motion, AnimatePresence } from 'framer-motion';
import { FlaskConical, Keyboard, MessageCircle, MessageSquareIcon, Sparkle, Sparkles, X } from 'lucide-react'
import Image from 'next/image';
import { send } from 'process';
import learnhouseAI_icon from "public/learnhouse_ai_simple.png";
import learnhouseAI_logo_black from "public/learnhouse_ai_black_logo.png";
import React, { use, useEffect, useRef } from 'react'
import { AIChatBotStateTypes, useAIChatBot, useAIChatBotDispatch } from '@components/Contexts/AI/AIChatBotContext';
import FeedbackModal from '@components/Objects/Modals/Feedback/Feedback';
import Modal from '@components/StyledElements/Modal/Modal';
import useGetAIFeatures from '../../../AI/Hooks/useGetAIFeatures';
type AIActivityAskProps = {
activity: any;
}
function AIActivityAsk(props: AIActivityAskProps) {
const is_ai_feature_enabled = useGetAIFeatures({ feature: 'activity_ask' });
const [isButtonAvailable, setIsButtonAvailable] = React.useState(false);
const dispatchAIChatBot = useAIChatBotDispatch() as any;
useEffect(() => {
if (is_ai_feature_enabled) {
setIsButtonAvailable(true);
}
}
, [is_ai_feature_enabled]);
return (
<>
{isButtonAvailable && (
<div >
<ActivityChatMessageBox activity={props.activity} />
<div
onClick={() => dispatchAIChatBot({ type: 'setIsModalOpen' })}
style={{
background: 'conic-gradient(from 32deg at 53.75% 50%, rgb(35, 40, 93) 4deg, rgba(20, 0, 52, 0.95) 59deg, rgba(164, 45, 238, 0.88) 281deg)',
}}
className="rounded-full px-5 drop-shadow-md flex items-center space-x-1.5 p-2.5 text-sm text-white hover:cursor-pointer transition delay-150 duration-300 ease-in-out hover:scale-105">
{" "}
<i>
<Image className='outline outline-1 outline-neutral-200/20 rounded-md' width={20} src={learnhouseAI_icon} alt="" />
</i>{" "}
<i className="not-italic text-xs font-bold">Ask AI</i>
</div>
</div>
)}
</>
)
}
export type AIMessage = {
sender: string;
message: any;
type: 'ai' | 'user';
}
type ActivityChatMessageBoxProps = {
activity: any;
}
function ActivityChatMessageBox(props: ActivityChatMessageBoxProps) {
const session = useSession() as any;
const aiChatBotState = useAIChatBot() as AIChatBotStateTypes;
const dispatchAIChatBot = useAIChatBotDispatch() as any;
// TODO : come up with a better way to handle this
const inputClass = aiChatBotState.isWaitingForResponse
? 'ring-1 ring-inset ring-white/10 bg-gray-950/40 w-full rounded-lg outline-none px-4 py-2 text-white text-sm placeholder:text-white/30 opacity-30 '
: 'ring-1 ring-inset ring-white/10 bg-gray-950/40 w-full rounded-lg outline-none px-4 py-2 text-white text-sm placeholder:text-white/30';
useEffect(() => {
if (aiChatBotState.isModalOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
}
}, [aiChatBotState.isModalOpen]);
function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Enter') {
// Perform the sending action here
sendMessage(event.currentTarget.value);
}
}
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
await dispatchAIChatBot({ type: 'setChatInputValue', payload: event.currentTarget.value });
}
const sendMessage = async (message: string) => {
if (aiChatBotState.aichat_uuid) {
await dispatchAIChatBot({ type: 'addMessage', payload: { sender: 'user', message: message, type: 'user' } });
await dispatchAIChatBot({ type: 'setIsWaitingForResponse' });
const response = await sendActivityAIChatMessage(message, aiChatBotState.aichat_uuid, props.activity.activity_uuid)
if (response.success == false) {
await dispatchAIChatBot({ type: 'setIsNoLongerWaitingForResponse' });
await dispatchAIChatBot({ type: 'setChatInputValue', payload: '' });
await dispatchAIChatBot({ type: 'setError', payload: { isError: true, status: response.status, error_message: response.data.detail } });
return;
}
await dispatchAIChatBot({ type: 'setIsNoLongerWaitingForResponse' });
await dispatchAIChatBot({ type: 'setChatInputValue', payload: '' });
await dispatchAIChatBot({ type: 'addMessage', payload: { sender: 'ai', message: response.data.message, type: 'ai' } });
} else {
await dispatchAIChatBot({ type: 'addMessage', payload: { sender: 'user', message: message, type: 'user' } });
await dispatchAIChatBot({ type: 'setIsWaitingForResponse' });
const response = await startActivityAIChatSession(message, props.activity.activity_uuid)
if (response.success == false) {
await dispatchAIChatBot({ type: 'setIsNoLongerWaitingForResponse' });
await dispatchAIChatBot({ type: 'setChatInputValue', payload: '' });
await dispatchAIChatBot({ type: 'setError', payload: { isError: true, status: response.status, error_message: response.data.detail } });
return;
}
await dispatchAIChatBot({ type: 'setAichat_uuid', payload: response.data.aichat_uuid });
await dispatchAIChatBot({ type: 'setIsNoLongerWaitingForResponse' });
await dispatchAIChatBot({ type: 'setChatInputValue', payload: '' });
await dispatchAIChatBot({ type: 'addMessage', payload: { sender: 'ai', message: response.data.message, type: 'ai' } });
}
}
function closeModal() {
dispatchAIChatBot({ type: 'setIsModalClose' });
}
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [aiChatBotState.messages, session]);
return (
<AnimatePresence>
{aiChatBotState.isModalOpen && (
<>
<motion.div
initial={{ y: 20, opacity: 0.3, filter: 'blur(5px)' }}
animate={{ y: 0, opacity: 1, filter: 'blur(0px)' }}
exit={{ y: 50, opacity: 0, filter: 'blur(25px)' }}
transition={{ type: "spring", bounce: 0.35, duration: 1.7, mass: 0.2, velocity: 2 }}
className='fixed top-0 left-0 w-full h-full z-50 flex justify-center items-center '
style={{ pointerEvents: 'none' }}
>
<div
style={{
pointerEvents: 'auto',
background: 'linear-gradient(0deg, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0.2) 100%), radial-gradient(105.16% 105.16% at 50% -5.16%, rgba(255, 255, 255, 0.18) 0%, rgba(0, 0, 0, 0) 100%), rgb(2 1 25 / 98%)'
}}
className="bg-black z-50 rounded-2xl max-w-screen-2xl w-10/12 my-10 mx-auto h-[350px] fixed bottom-0 left-1/2 transform -translate-x-1/2 shadow-lg ring-1 ring-inset ring-white/10 text-white p-4 flex-col-reverse backdrop-blur-md">
<div className='flex flex-row-reverse pb-3 justify-between items-center'>
<div className='flex space-x-2 items-center'>
<X size={20} className='text-white/50 hover:cursor-pointer bg-white/10 p-1 rounded-full items-center' onClick={closeModal} />
</div>
<div className={`flex space-x-2 items-center -ml-[100px] ${aiChatBotState.isWaitingForResponse ? 'animate-pulse' : ''}`}>
<Image className={`outline outline-1 outline-neutral-200/20 rounded-lg ${aiChatBotState.isWaitingForResponse ? 'animate-pulse' : ''}`} width={24} src={learnhouseAI_icon} alt="" />
<span className='text-sm font-semibold text-white/70'> AI</span>
</div>
<div className='bg-white/5 text-white/40 py-0.5 px-3 flex space-x-1 rounded-full items-center'>
<FlaskConical size={14} />
<span className='text-xs font-semibold '>Experimental</span>
</div>
</div>
<div className={`w-100 h-0.5 bg-white/5 rounded-full mx-auto mb-3 ${aiChatBotState.isWaitingForResponse ? 'animate-pulse' : ''}`}></div>
{aiChatBotState.messages.length > 0 && !aiChatBotState.error.isError ? (
<div className='flex-col h-[237px] w-full space-y-4 overflow-scroll scrollbar-w-2 scrollbar scrollbar-thumb-white/20 scrollbar-thumb-rounded-full scrollbar-track-rounded-full'>
{aiChatBotState.messages.map((message: AIMessage, index: number) => {
return (
<AIMessage key={index} message={message} animated={message.sender == 'ai' ? true : false} />
)
})}
<div ref={messagesEndRef} />
</div>
) : (
<AIMessagePlaceHolder sendMessage={sendMessage} activity_uuid={props.activity.activity_uuid} />
)}
{aiChatBotState.error.isError && (
<div className='flex items-center h-[237px]'>
<div className='flex flex-col mx-auto w-[600px] space-y-2 p-5 rounded-lg bg-red-500/20 outline outline-1 outline-red-500'>
<AlertTriangle size={20} className='text-red-500' />
<div className='flex flex-col'>
<h3 className='font-semibold text-red-200'>Something wrong happened</h3>
<span className='text-red-100 text-sm '>{aiChatBotState.error.error_message}</span>
</div>
</div>
</div>
)
}
<div className='flex space-x-2 items-center'>
<div className=''>
<Avvvatars radius={3} border borderColor='white' borderSize={3} size={35} value={session.user.user_uuid} style="shape" />
</div>
<div className='w-full'>
<input onKeyDown={handleKeyDown} onChange={handleChange} disabled={aiChatBotState.isWaitingForResponse} value={aiChatBotState.chatInputValue} placeholder='Ask AI About this Lecture' type="text" className={inputClass} name="" id="" />
</div>
<div className=''>
<MessageCircle size={20} className='text-white/50 hover:cursor-pointer' onClick={() => sendMessage(aiChatBotState.chatInputValue)} />
</div>
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
)
}
type AIMessageProps = {
message: AIMessage;
animated: boolean;
}
function AIMessage(props: AIMessageProps) {
const session = useSession() as any;
const words = props.message.message.split(' ');
return (
<div className='flex space-x-2 w-full antialiased font-medium'>
<div className=''>
<Avvvatars radius={3} border borderColor='white' borderSize={3} size={35} value={props.message.type == 'ai' ? 'ai' : session.user.user_uuid} style="shape" />
</div>
<div className='w-full'>
<p className='w-full rounded-lg outline-none px-2 py-1 text-white text-md placeholder:text-white/30' id="">
<AnimatePresence>
{words.map((word: string, i: number) => (
<motion.span
key={i}
initial={props.animated ? { opacity: 0, y: -10 } : { opacity: 1, y: 0 }}
animate={{ opacity: 1, y: 0 }}
exit={props.animated ? { opacity: 0, y: 10 } : { opacity: 1, y: 0 }}
transition={props.animated ? { delay: i * 0.1 } : {}}
>
{word + ' '}
</motion.span>
))}
</AnimatePresence>
</p>
</div>
</div>
)
}
const AIMessagePlaceHolder = (props: { activity_uuid: string, sendMessage: any }) => {
const session = useSession() as any;
const [feedbackModal, setFeedbackModal] = React.useState(false);
const aiChatBotState = useAIChatBot() as AIChatBotStateTypes;
if (!aiChatBotState.error.isError) {
return <div className='flex-col h-[237px] w-full'>
<div className='flex flex-col text-center justify-center pt-12'>
<motion.div
initial={{ y: 20, opacity: 0, filter: 'blur(5px)' }}
animate={{ y: 0, opacity: 1, filter: 'blur(0px)' }}
exit={{ y: 50, opacity: 0, }}
transition={{ type: "spring", bounce: 0.35, duration: 1.7, mass: 0.2, velocity: 2, delay: 0.17 }}
>
<Image width={100} className='mx-auto' src={learnhouseAI_logo_black} alt="" />
<p className='pt-3 text-2xl font-semibold text-white/70 flex justify-center space-x-2 items-center'>
<span className='items-center'>Hello</span>
<span className='capitalize flex space-x-2 items-center'> <Avvvatars radius={3} border borderColor='white' borderSize={3} size={25} value={session.user.user_uuid} style="shape" />
<span>{session.user.username},</span>
</span>
<span>how can we help today ?</span>
</p>
</motion.div>
<motion.div
initial={{ y: 20, opacity: 0, filter: 'blur(5px)' }}
animate={{ y: 0, opacity: 1, filter: 'blur(0px)' }}
exit={{ y: 50, opacity: 0, }}
transition={{ type: "spring", bounce: 0.35, duration: 1.7, mass: 0.2, velocity: 2, delay: 0.27 }}
className='questions flex space-x-3 mx-auto pt-6 flex-wrap justify-center'
>
<AIChatPredefinedQuestion sendMessage={props.sendMessage} label='about' />
<AIChatPredefinedQuestion sendMessage={props.sendMessage} label='flashcards' />
<AIChatPredefinedQuestion sendMessage={props.sendMessage} label='examples' />
</motion.div>
</div>
</div>
}
}
const AIChatPredefinedQuestion = (props: { sendMessage: any, label: string }) => {
function getQuestion(label: string) {
if (label === 'about') {
return `What is this Activity about ?`
} else if (label === 'flashcards') {
return `Generate flashcards about this Activity`
} else if (label === 'examples') {
return `Explain this Activity in practical examples`
}
}
return (
<div onClick={() => props.sendMessage(getQuestion(props.label))} className='flex space-x-1.5 items-center bg-white/5 cursor-pointer px-4 py-1.5 rounded-xl outline outline-1 outline-neutral-100/10 text-xs font-semibold text-white/40 hover:text-white/60 hover:bg-white/10 hover:outline-neutral-200/40 delay-75 ease-linear transition-all'>
{props.label === 'about' && <BadgeInfo size={15} />}
{props.label === 'flashcards' && <NotebookTabs size={15} />}
{props.label === 'examples' && <div className='text-white/50'>Ex</div>}
<span>{getQuestion(props.label)}</span>
</div>
)
}
export default AIActivityAsk

View file

@ -0,0 +1,131 @@
import React from 'react'
import { Editor } from '@tiptap/core';
import learnhouseAI_icon from "public/learnhouse_ai_simple.png";
import Image from 'next/image';
import { BookOpen, FormInput, Languages, MoreVertical } from 'lucide-react';
import { BubbleMenu } from '@tiptap/react';
import ToolTip from '@components/StyledElements/Tooltip/Tooltip';
import { AIChatBotStateTypes, useAIChatBot, useAIChatBotDispatch } from '@components/Contexts/AI/AIChatBotContext';
import { sendActivityAIChatMessage, startActivityAIChatSession } from '@services/ai/ai';
import useGetAIFeatures from '../../../../AI/Hooks/useGetAIFeatures';
type AICanvaToolkitProps = {
editor: Editor,
activity: any
}
function AICanvaToolkit(props: AICanvaToolkitProps) {
const is_ai_feature_enabled = useGetAIFeatures({ feature: 'activity_ask' });
const [isBubbleMenuAvailable, setIsButtonAvailable] = React.useState(false);
React.useEffect(() => {
if (is_ai_feature_enabled) {
setIsButtonAvailable(true);
}
}, [is_ai_feature_enabled])
return (
<>
{isBubbleMenuAvailable && <BubbleMenu className="w-fit" tippyOptions={{ duration: 100 }} editor={props.editor}>
<div style={{ background: 'linear-gradient(0deg, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0.2) 100%), radial-gradient(105.16% 105.16% at 50% -5.16%, rgba(255, 255, 255, 0.18) 0%, rgba(0, 0, 0, 0) 100%), rgba(2, 1, 25, 0.98)' }}
className='py-1 h-10 px-2 w-max text-white rounded-xl shadow-md cursor-pointer flex items-center space-x-2 antialiased'
>
<div className='flex w-full space-x-2 font-bold text-white/80'><Image className='outline outline-1 outline-neutral-200/10 rounded-lg' width={24} src={learnhouseAI_icon} alt="" /> <div>AI</div> </div>
<div>
<MoreVertical className='text-white/50' size={12} />
</div>
<div className='flex space-x-2'>
<AIActionButton editor={props.editor} activity={props.activity} label='Explain' />
<AIActionButton editor={props.editor} activity={props.activity} label='Summarize' />
<AIActionButton editor={props.editor} activity={props.activity} label='Translate' />
<AIActionButton editor={props.editor} activity={props.activity} label='Examples' />
</div>
</div>
</BubbleMenu>}
</>
)
}
function AIActionButton(props: { editor: Editor, label: string, activity: any }) {
const dispatchAIChatBot = useAIChatBotDispatch() as any;
const aiChatBotState = useAIChatBot() as AIChatBotStateTypes;
async function handleAction(label: string) {
const selection = getTipTapEditorSelectedText();
const prompt = getPrompt(label, selection);
dispatchAIChatBot({ type: 'setIsModalOpen' });
await sendMessage(prompt);
}
const getTipTapEditorSelectedText = () => {
const selection = props.editor.state.selection;
const from = selection.from;
const to = selection.to;
const text = props.editor.state.doc.textBetween(from, to);
return text;
}
const getPrompt = (label: string, selection: string) => {
if (label === 'Explain') {
return `Explain this part of the course "${selection}" keep this course context in mind.`
} else if (label === 'Summarize') {
return `Summarize this "${selection}" with the course context in mind.`
} else if (label === 'Translate') {
return `Translate "${selection}" to another language.`
} else {
return `Give examples to understand "${selection}" better, if possible give context in the course.`
}
}
const sendMessage = async (message: string) => {
if (aiChatBotState.aichat_uuid) {
await dispatchAIChatBot({ type: 'addMessage', payload: { sender: 'user', message: message, type: 'user' } });
await dispatchAIChatBot({ type: 'setIsWaitingForResponse' });
const response = await sendActivityAIChatMessage(message, aiChatBotState.aichat_uuid, props.activity.activity_uuid)
if (response.success == false) {
await dispatchAIChatBot({ type: 'setIsNoLongerWaitingForResponse' });
await dispatchAIChatBot({ type: 'setChatInputValue', payload: '' });
await dispatchAIChatBot({ type: 'setError', payload: { isError: true, status: response.status, error_message: response.data.detail } });
return;
}
await dispatchAIChatBot({ type: 'setIsNoLongerWaitingForResponse' });
await dispatchAIChatBot({ type: 'setChatInputValue', payload: '' });
await dispatchAIChatBot({ type: 'addMessage', payload: { sender: 'ai', message: response.data.message, type: 'ai' } });
} else {
await dispatchAIChatBot({ type: 'addMessage', payload: { sender: 'user', message: message, type: 'user' } });
await dispatchAIChatBot({ type: 'setIsWaitingForResponse' });
const response = await startActivityAIChatSession(message, props.activity.activity_uuid)
if (response.success == false) {
await dispatchAIChatBot({ type: 'setIsNoLongerWaitingForResponse' });
await dispatchAIChatBot({ type: 'setChatInputValue', payload: '' });
await dispatchAIChatBot({ type: 'setError', payload: { isError: true, status: response.status, error_message: response.data.detail } });
return;
}
await dispatchAIChatBot({ type: 'setAichat_uuid', payload: response.data.aichat_uuid });
await dispatchAIChatBot({ type: 'setIsNoLongerWaitingForResponse' });
await dispatchAIChatBot({ type: 'setChatInputValue', payload: '' });
await dispatchAIChatBot({ type: 'addMessage', payload: { sender: 'ai', message: response.data.message, type: 'ai' } });
}
}
const tooltipLabel = props.label === 'Explain' ? 'Explain a word or a sentence with AI' : props.label === 'Summarize' ? 'Summarize a long paragraph or text with AI' : props.label === 'Translate' ? 'Translate to different languages with AI' : 'Give examples to understand better with AI'
return (
<div className='flex space-x-2' >
<ToolTip sideOffset={10} slateBlack content={tooltipLabel}>
<button onClick={() => handleAction(props.label)} className='flex space-x-1.5 items-center bg-white/10 px-2 py-0.5 rounded-md outline outline-1 outline-neutral-200/20 text-sm font-semibold text-white/70 hover:bg-white/20 hover:outline-neutral-200/40 delay-75 ease-linear transition-all'>
{props.label === 'Explain' && <BookOpen size={16} />}
{props.label === 'Summarize' && <FormInput size={16} />}
{props.label === 'Translate' && <Languages size={16} />}
{props.label === 'Examples' && <div className='text-white/50'>Ex</div>}
<div>{props.label}</div>
</button>
</ToolTip>
</div>
)
}
export default AICanvaToolkit

View file

@ -1,4 +1,4 @@
import { useEditor, EditorContent } from "@tiptap/react";
import { useEditor, EditorContent, BubbleMenu, EditorProvider } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import styled from "styled-components"
import Youtube from "@tiptap/extension-youtube";
@ -22,16 +22,24 @@ import ts from 'highlight.js/lib/languages/typescript'
import html from 'highlight.js/lib/languages/xml'
import python from 'highlight.js/lib/languages/python'
import java from 'highlight.js/lib/languages/java'
import { NoTextInput } from "@components/Objects/Editor/Extensions/NoTextInput/NoTextInput";
import EditorOptionsProvider from "@components/Contexts/Editor/EditorContext";
import AICanvaToolkit from "./AI/AICanvaToolkit";
interface Editor {
content: string;
activity: any;
//course: any;
}
function Canva(props: Editor) {
const isEditable = false;
/**
* Important Note : This is a workaround to enable user interaction features to be implemented easily, like text selection, AI features and other planned features, this is set to true but otherwise it should be set to false.
* Another workaround is implemented below to disable the editor from being edited by the user by setting the caret-color to transparent and using a custom extension to filter out transactions that add/edit/remove text.
* To let the various Custom Extensions know that the editor is not editable, React context (EditorOptionsProvider) will be used instead of props.extension.options.editable.
*/
const isEditable = true;
// Code Block Languages for Lowlight
lowlight.register('html', html)
@ -46,6 +54,7 @@ function Canva(props: Editor) {
editable: isEditable,
extensions: [
StarterKit,
NoTextInput,
// Custom Extensions
InfoCallout.configure({
editable: isEditable,
@ -87,21 +96,54 @@ function Canva(props: Editor) {
content: props.content,
});
return (
<CanvaWrapper>
<EditorContent editor={editor} />
</CanvaWrapper>
<EditorOptionsProvider options={{ isEditable: false }}>
<CanvaWrapper>
<AICanvaToolkit activity={props.activity} editor={editor} />
<EditorContent editor={editor} />
</CanvaWrapper>
</EditorOptionsProvider>
);
}
const CanvaWrapper = styled.div`
width: 100%;
margin: 0 auto;
.bubble-menu {
display: flex;
background-color: #0D0D0D;
padding: 0.2rem;
border-radius: 0.5rem;
button {
border: none;
background: none;
color: #FFF;
font-size: 0.85rem;
font-weight: 500;
padding: 0 0.2rem;
opacity: 0.6;
&:hover,
&.is-active {
opacity: 1;
}
}
}
// disable chrome outline
.ProseMirror {
// Workaround to disable editor from being edited by the user.
caret-color: transparent;
h1 {
font-size: 30px;
font-weight: 600;

View file

@ -0,0 +1,462 @@
import React from 'react'
import learnhouseAI_icon from "public/learnhouse_ai_simple.png";
import { motion, AnimatePresence } from 'framer-motion';
import Image from 'next/image';
import { AlertTriangle, BetweenHorizontalStart, FastForward, Feather, FileStack, HelpCircle, Languages, MessageCircle, MoreVertical, Pen, X } from 'lucide-react';
import { Editor } from '@tiptap/react';
import { AIChatBotStateTypes, useAIChatBot, useAIChatBotDispatch } from '@components/Contexts/AI/AIChatBotContext';
import { AIEditorStateTypes, useAIEditor, useAIEditorDispatch } from '@components/Contexts/AI/AIEditorContext';
import { sendActivityAIChatMessage, startActivityAIChatSession } from '@services/ai/ai';
import useGetAIFeatures from '@components/AI/Hooks/useGetAIFeatures';
type AIEditorToolkitProps = {
editor: Editor,
activity: any
}
type AIPromptsLabels = {
label: 'Writer' | 'ContinueWriting' | 'MakeLonger' | 'GenerateQuiz' | 'Translate',
selection: string
}
function AIEditorToolkit(props: AIEditorToolkitProps) {
const dispatchAIEditor = useAIEditorDispatch() as any;
const aiEditorState = useAIEditor() as AIEditorStateTypes;
const is_ai_feature_enabled = useGetAIFeatures({ feature: 'editor' });
const [isToolkitAvailable, setIsToolkitAvailable] = React.useState(true);
React.useEffect(() => {
if (is_ai_feature_enabled) {
setIsToolkitAvailable(true);
}
}, [is_ai_feature_enabled])
return (
<>
{isToolkitAvailable && <div className='flex space-x-2'>
<AnimatePresence>
{aiEditorState.isModalOpen && <motion.div
initial={{ y: 20, opacity: 0.3, filter: 'blur(5px)' }}
animate={{ y: 0, opacity: 1, filter: 'blur(0px)' }}
exit={{ y: 50, opacity: 0, filter: 'blur(3px)' }}
transition={{ type: "spring", bounce: 0.35, duration: 1.7, mass: 0.2, velocity: 2 }}
className='fixed top-0 left-0 w-full h-full z-50 flex justify-center items-center '
style={{ pointerEvents: 'none' }}
>
<>
{aiEditorState.isFeedbackModalOpen && <UserFeedbackModal activity={props.activity} editor={props.editor} />}
<div
style={{
pointerEvents: 'auto',
background: 'linear-gradient(0deg, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0.2) 100%), radial-gradient(105.16% 105.16% at 50% -5.16%, rgba(255, 255, 255, 0.18) 0%, rgba(0, 0, 0, 0) 100%), rgb(2 1 25 / 98%)'
}}
className="z-50 rounded-2xl max-w-screen-2xl my-10 mx-auto w-fit fixed bottom-0 left-1/2 transform -translate-x-1/2 shadow-xl ring-1 ring-inset ring-white/10 text-white p-3 flex-col-reverse backdrop-blur-md">
<div className='flex space-x-2'>
<div className='pr-1'>
<div className='flex w-full space-x-2 font-bold text-white/80 items-center'>
<Image className='outline outline-1 outline-neutral-200/20 rounded-lg' width={24} src={learnhouseAI_icon} alt="" />
<div >AI Editor</div>
<MoreVertical className='text-white/50' size={12} />
</div>
</div>
<div className='tools flex space-x-2'>
<AiEditorToolButton label='Writer' />
<AiEditorToolButton label='ContinueWriting' />
<AiEditorToolButton label='MakeLonger' />
<AiEditorToolButton label='Translate' />
</div>
<div className='flex space-x-2 items-center'>
<X onClick={() => Promise.all([dispatchAIEditor({ type: 'setIsModalClose' }), dispatchAIEditor({ type: 'setIsFeedbackModalClose' })])} size={20} className='text-white/50 hover:cursor-pointer bg-white/10 p-1 rounded-full items-center' />
</div>
</div>
</div></>
</motion.div>}
</AnimatePresence>
</div>}
</>
)
}
const UserFeedbackModal = (props: AIEditorToolkitProps) => {
const dispatchAIEditor = useAIEditorDispatch() as any;
const aiEditorState = useAIEditor() as AIEditorStateTypes;
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
await dispatchAIEditor({ type: 'setChatInputValue', payload: event.currentTarget.value });
}
const sendReqWithMessage = async (message: string) => {
if (aiEditorState.aichat_uuid) {
await dispatchAIEditor({ type: 'addMessage', payload: { sender: 'user', message: message, type: 'user' } });
await dispatchAIEditor({ type: 'setIsWaitingForResponse' });
const response = await sendActivityAIChatMessage(message, aiEditorState.aichat_uuid, props.activity.activity_uuid)
if (response.success === false) {
await dispatchAIEditor({ type: 'setIsNoLongerWaitingForResponse' });
await dispatchAIEditor({ type: 'setIsModalClose' });
// wait for 200ms before opening the modal again
await new Promise(resolve => setTimeout(resolve, 200));
await dispatchAIEditor({ type: 'setError', payload: { isError: true, status: response.status, error_message: response.data.detail } });
await dispatchAIEditor({ type: 'setIsModalOpen' });
return '';
}
await dispatchAIEditor({ type: 'setIsNoLongerWaitingForResponse' });
await dispatchAIEditor({ type: 'setChatInputValue', payload: '' });
await dispatchAIEditor({ type: 'addMessage', payload: { sender: 'ai', message: response.data.message, type: 'ai' } });
return response.data.message;
} else {
await dispatchAIEditor({ type: 'addMessage', payload: { sender: 'user', message: message, type: 'user' } });
await dispatchAIEditor({ type: 'setIsWaitingForResponse' });
const response = await startActivityAIChatSession(message, props.activity.activity_uuid)
if (response.success === false) {
await dispatchAIEditor({ type: 'setIsNoLongerWaitingForResponse' });
await dispatchAIEditor({ type: 'setIsModalClose' });
// wait for 200ms before opening the modal again
await new Promise(resolve => setTimeout(resolve, 200));
await dispatchAIEditor({ type: 'setError', payload: { isError: true, status: response.status, error_message: response.data.detail } });
await dispatchAIEditor({ type: 'setIsModalOpen' });
return '';
}
await dispatchAIEditor({ type: 'setAichat_uuid', payload: response.data.aichat_uuid });
await dispatchAIEditor({ type: 'setIsNoLongerWaitingForResponse' });
await dispatchAIEditor({ type: 'setChatInputValue', payload: '' });
await dispatchAIEditor({ type: 'addMessage', payload: { sender: 'ai', message: response.data.message, type: 'ai' } });
return response.data.message;
}
}
const handleKeyPress = async (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
await handleOperation(aiEditorState.selectedTool, aiEditorState.chatInputValue);
}
}
const handleOperation = async (label: 'Writer' | 'ContinueWriting' | 'MakeLonger' | 'GenerateQuiz' | 'Translate', message: string) => {
// Set selected tool
await dispatchAIEditor({ type: 'setSelectedTool', payload: label });
// Check what operation that was
if (label === 'Writer') {
let ai_message = '';
let prompt = getPrompt({ label: label, selection: message });
await dispatchAIEditor({ type: 'setIsUserInputEnabled', payload: true });
if (prompt) {
await dispatchAIEditor({ type: 'setIsUserInputEnabled', payload: false });
await dispatchAIEditor({ type: 'setIsWaitingForResponse' });
ai_message = await sendReqWithMessage(prompt);
await fillEditorWithText(ai_message);
await dispatchAIEditor({ type: 'setIsNoLongerWaitingForResponse' });
await dispatchAIEditor({ type: 'setIsUserInputEnabled', payload: true });
}
} else if (label === 'ContinueWriting') {
let ai_message = '';
let text_selection = getTipTapEditorSelectedTextGlobal();
let prompt = getPrompt({ label: label, selection: text_selection });
if (prompt) {
await dispatchAIEditor({ type: 'setIsWaitingForResponse' });
ai_message = await sendReqWithMessage(prompt);
const message_without_original_text = await removeSentences(text_selection, ai_message);
await fillEditorWithText(message_without_original_text);
await dispatchAIEditor({ type: 'setIsNoLongerWaitingForResponse' });
}
} else if (label === 'MakeLonger') {
let ai_message = '';
let text_selection = getTipTapEditorSelectedText();
let prompt = getPrompt({ label: label, selection: text_selection });
if (prompt) {
await dispatchAIEditor({ type: 'setIsWaitingForResponse' });
ai_message = await sendReqWithMessage(prompt);
await replaceSelectedTextWithText(ai_message);
await dispatchAIEditor({ type: 'setIsNoLongerWaitingForResponse' });
}
} else if (label === 'GenerateQuiz') {
// will be implemented in future stages
} else if (label === 'Translate') {
let ai_message = '';
let text_selection = getTipTapEditorSelectedText();
let prompt = getPrompt({ label: label, selection: text_selection });
if (prompt) {
await dispatchAIEditor({ type: 'setIsWaitingForResponse' });
ai_message = await sendReqWithMessage(prompt);
await replaceSelectedTextWithText(ai_message);
await dispatchAIEditor({ type: 'setIsNoLongerWaitingForResponse' });
}
}
}
const removeSentences = async (textToRemove: string, originalText: string) => {
const phrase = textToRemove.toLowerCase();
const original = originalText.toLowerCase();
if (original.includes(phrase)) {
const regex = new RegExp(phrase, 'g');
const newText = original.replace(regex, '');
return newText;
} else {
return originalText;
}
}
async function fillEditorWithText(text: string) {
const words = text.split(' ');
for (let i = 0; i < words.length; i++) {
const textNode = {
type: 'text',
text: words[i],
};
props.editor.chain().focus().insertContent(textNode).run();
// Add a space after each word except the last one
if (i < words.length - 1) {
const spaceNode = {
type: 'text',
text: ' ',
};
props.editor.chain().focus().insertContent(spaceNode).run();
}
// Wait for 0.3 seconds before adding the next word
await new Promise(resolve => setTimeout(resolve, 120));
}
}
async function replaceSelectedTextWithText(text: string) {
const words = text.split(' ');
// Delete the selected text
props.editor.chain().focus().deleteSelection().run();
for (let i = 0; i < words.length; i++) {
const textNode = {
type: 'text',
text: words[i],
};
props.editor.chain().focus().insertContent(textNode).run();
// Add a space after each word except the last one
if (i < words.length - 1) {
const spaceNode = {
type: 'text',
text: ' ',
};
props.editor.chain().focus().insertContent(spaceNode).run();
}
// Wait for 0.3 seconds before adding the next word
await new Promise(resolve => setTimeout(resolve, 120));
}
}
const getPrompt = (args: AIPromptsLabels) => {
const { label, selection } = args;
if (label === 'Writer') {
return `Write 3 sentences about ${selection}`;
} else if (label === 'ContinueWriting') {
return `Continue writing 3 more sentences based on "${selection}"`;
} else if (label === 'MakeLonger') {
return `Make longer this text longer : "${selection}"`;
} else if (label === 'GenerateQuiz') {
return `Generate a quiz about "${selection}", only return an array of objects, every object should respect the following interface:
interface Answer {
answer_id: string;
answer: string;
correct: boolean;
}
interface Question {
question_id: string;
question: string;
type: "multiple_choice"
answers: Answer[];
}
" `;
} else if (label === 'Translate') {
return `Translate "${selection}" to the ` + aiEditorState.chatInputValue + ` language`;
}
}
const getTipTapEditorSelectedTextGlobal = () => {
// Get the entire node/paragraph that the user is in
const pos = props.editor.state.selection.$from.pos; // get the cursor position
const resolvedPos = props.editor.state.doc.resolve(pos); // resolve the position in the document
const start = resolvedPos.before(1); // get the start position of the node
const end = resolvedPos.after(1); // get the end position of the node
const paragraph = props.editor.state.doc.textBetween(start, end, '\n', '\n'); // get the text of the node
return paragraph;
}
const getTipTapEditorSelectedText = () => {
const selection = props.editor.state.selection;
const from = selection.from;
const to = selection.to;
const text = props.editor.state.doc.textBetween(from, to);
return text;
}
return (
<motion.div
initial={{ y: 20, opacity: 0.3, filter: 'blur(5px)' }}
animate={{ y: 0, opacity: 1, filter: 'blur(0px)' }}
exit={{ y: 50, opacity: 0, filter: 'blur(3px)' }}
transition={{ type: "spring", bounce: 0.35, duration: 1.7, mass: 0.2, velocity: 2 }}
className='backdrop-blur-md fixed top-0 left-0 w-full h-full z-50 flex justify-center items-center '
style={{ pointerEvents: 'none' }}
>
<div
style={{
pointerEvents: 'auto',
background: 'linear-gradient(0deg, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0.2) 100%), radial-gradient(105.16% 105.16% at 50% -5.16%, rgba(255, 255, 255, 0.18) 0%, rgba(0, 0, 0, 0) 100%), rgb(2 1 25 / 95%)'
}}
className="backdrop-blur-md z-50 rounded-2xl max-w-screen-2xl my-10 mx-auto w-[500px] h-[200px] fixed bottom-16 left-1/2 transform -translate-x-1/2 shadow-xl ring-1 ring-inset ring-white/10 text-white p-3 flex-col-reverse">
<div className='flex space-x-2 justify-center'>
<Image className='outline outline-1 outline-neutral-200/20 rounded-lg' width={24} src={learnhouseAI_icon} alt="" />
</div>
<div className='flex h-[115px] justify-center mx-auto antialiased'>
<div className='flex items-center justify-center '>
<AiEditorActionScreen handleOperation={handleOperation} />
</div>
</div>
{aiEditorState.isUserInputEnabled && !aiEditorState.error.isError && <div className="flex items-center space-x-2 cursor-pointer">
<input onKeyDown={handleKeyPress} value={aiEditorState.chatInputValue} onChange={handleChange} placeholder='Ask AI' className='ring-1 ring-inset ring-white/20 w-full bg-gray-950/20 rounded-lg outline-none px-4 py-2 text-white text-sm placeholder:text-white/30'></input>
<div onClick={() => handleOperation(aiEditorState.selectedTool, aiEditorState.chatInputValue)} className='bg-white/10 px-3 rounded-md outline outline-1 outline-neutral-200/20 py-2 hover:bg-white/20 hover:outline-neutral-200/40 delay-75 ease-linear transition-all'>
<BetweenHorizontalStart size={20} className='text-white/50 hover:cursor-pointer' />
</div>
</div>}
</div>
</motion.div>
)
}
const AiEditorToolButton = (props: any) => {
const dispatchAIEditor = useAIEditorDispatch() as any;
const aiEditorState = useAIEditor() as AIEditorStateTypes;
const handleToolButtonClick = async (label: 'Writer' | 'ContinueWriting' | 'MakeLonger' | 'GenerateQuiz' | 'Translate') => {
if (label === 'Writer') {
await dispatchAIEditor({ type: 'setSelectedTool', payload: label });
await dispatchAIEditor({ type: 'setIsUserInputEnabled', payload: true });
await dispatchAIEditor({ type: 'setIsFeedbackModalOpen' });
}
if (label === 'ContinueWriting') {
await dispatchAIEditor({ type: 'setSelectedTool', payload: label });
await dispatchAIEditor({ type: 'setIsUserInputEnabled', payload: false });
await dispatchAIEditor({ type: 'setIsFeedbackModalOpen' });
}
if (label === 'MakeLonger') {
await dispatchAIEditor({ type: 'setSelectedTool', payload: label });
await dispatchAIEditor({ type: 'setIsUserInputEnabled', payload: false });
await dispatchAIEditor({ type: 'setIsFeedbackModalOpen' });
}
if (label === 'GenerateQuiz') {
await dispatchAIEditor({ type: 'setSelectedTool', payload: label });
await dispatchAIEditor({ type: 'setIsUserInputEnabled', payload: false });
await dispatchAIEditor({ type: 'setIsFeedbackModalOpen' });
}
if (label === 'Translate') {
await dispatchAIEditor({ type: 'setSelectedTool', payload: label });
await dispatchAIEditor({ type: 'setIsUserInputEnabled', payload: false });
await dispatchAIEditor({ type: 'setIsFeedbackModalOpen' });
}
}
return (
<button onClick={() => handleToolButtonClick(props.label)} className='flex space-x-1.5 items-center bg-white/10 px-2 py-0.5 rounded-md outline outline-1 outline-neutral-200/20 text-sm font-semibold text-white/70 hover:bg-white/20 hover:outline-neutral-200/40 delay-75 ease-linear transition-all'>
{props.label === 'Writer' && <Feather size={14} />}
{props.label === 'ContinueWriting' && <FastForward size={14} />}
{props.label === 'MakeLonger' && <FileStack size={14} />}
{props.label === 'GenerateQuiz' && <HelpCircle size={14} />}
{props.label === 'Translate' && <Languages size={14} />}
<span>{props.label}</span>
</button>
)
}
const AiEditorActionScreen = ({ handleOperation }: { handleOperation: any }) => {
const dispatchAIEditor = useAIEditorDispatch() as any;
const aiEditorState = useAIEditor() as AIEditorStateTypes;
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
await dispatchAIEditor({ type: 'setChatInputValue', payload: event.currentTarget.value });
}
return (
<div>
{aiEditorState.selectedTool === 'Writer' && !aiEditorState.isWaitingForResponse && !aiEditorState.error.isError &&
<div className='text-xl text-white/90 font-extrabold space-x-2'>
<span>Write about...</span>
</div>}
{aiEditorState.selectedTool === 'ContinueWriting' && !aiEditorState.isWaitingForResponse && !aiEditorState.error.isError &&
<div className='flex flex-col mx-auto justify-center align-middle items-center'>
<p className='mx-auto flex p-2 text-white/80 mt-4 font-bold justify-center text-sm align-middle'>Place your cursor at the end of a sentence to continue writing </p>
<div onClick={() => {
handleOperation(aiEditorState.selectedTool, aiEditorState.chatInputValue)
}} className='flex cursor-pointer space-x-1.5 p-4 mt-4 items-center bg-white/10 rounded-md outline outline-1 outline-neutral-200/20 text-2xl font-semibold text-white/70 hover:bg-white/20 hover:outline-neutral-200/40 delay-75 ease-linear transition-all'>
<FastForward size={24} />
</div>
</div>}
{aiEditorState.selectedTool === 'MakeLonger' && !aiEditorState.isWaitingForResponse && !aiEditorState.error.isError &&
<div className='flex flex-col mx-auto justify-center align-middle items-center'>
<p className='mx-auto flex p-2 text-white/80 mt-4 font-bold justify-center text-sm align-middle'>Select text to make longer </p>
<div onClick={() => {
handleOperation(aiEditorState.selectedTool, aiEditorState.chatInputValue)
}} className='flex cursor-pointer space-x-1.5 p-4 mt-4 items-center bg-white/10 rounded-md outline outline-1 outline-neutral-200/20 text-2xl font-semibold text-white/70 hover:bg-white/20 hover:outline-neutral-200/40 delay-75 ease-linear transition-all'>
<FileStack size={24} />
</div>
</div>}
{aiEditorState.selectedTool === 'Translate' && !aiEditorState.isWaitingForResponse && !aiEditorState.error.isError &&
<div className='flex flex-col mx-auto justify-center align-middle items-center'>
<div className='mx-auto flex p-2 text-white/80 mt-4 font-bold justify-center text-sm align-middle space-x-6'>
<p>Translate selected text to </p>
<input value={aiEditorState.chatInputValue} onChange={handleChange} placeholder='Japanese, Arabic, German, etc. ' className='ring-1 ring-inset ring-white/20 w-full bg-gray-950/20 rounded-lg outline-none px-4 py- text-white text-sm placeholder:text-white/30'></input>
</div>
<div onClick={() => {
handleOperation(aiEditorState.selectedTool, aiEditorState.chatInputValue)
}} className='flex cursor-pointer space-x-1.5 p-4 mt-4 items-center bg-white/10 rounded-md outline outline-1 outline-neutral-200/20 text-2xl font-semibold text-white/70 hover:bg-white/20 hover:outline-neutral-200/40 delay-75 ease-linear transition-all'>
<Languages size={24} />
</div>
</div>}
{aiEditorState.isWaitingForResponse && !aiEditorState.error.isError && <div className='flex flex-col mx-auto justify-center align-middle items-center'>
<svg className="animate-spin mt-10 h-10 w-10 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p className='font-bold mt-4 text-white/90'>Thinking...</p>
</div>}
{aiEditorState.error.isError && (
<div className='flex items-center h-auto pt-7'>
<div className='flex flex-col mx-auto w-full space-y-2 p-5 rounded-lg bg-red-500/20 outline outline-1 outline-red-500'>
<AlertTriangle size={20} className='text-red-500' />
<div className='flex flex-col'>
<h3 className='font-semibold text-red-200'>Something wrong happened</h3>
<span className='text-red-100 text-sm '>{aiEditorState.error.error_message}</span>
</div>
</div>
</div>
)
}
</div>
)
}
export default AIEditorToolkit

View file

@ -1,6 +1,6 @@
'use client';
import React from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import { useEditor, EditorContent, BubbleMenu } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import learnhouseIcon from "public/learnhouse_icon.png";
import { ToolbarButtons } from "./Toolbar/ToolbarButtons";
@ -9,6 +9,9 @@ import Image from "next/image";
import styled from "styled-components";
import { DividerVerticalIcon, SlashIcon } from "@radix-ui/react-icons";
import Avvvatars from "avvvatars-react";
import learnhouseAI_icon from "public/learnhouse_ai_simple.png";
import { AIEditorStateTypes, useAIEditor, useAIEditorDispatch } from "@components/Contexts/AI/AIEditorContext";
// extensions
import InfoCallout from "./Extensions/Callout/Info/InfoCallout";
import WarningCallout from "./Extensions/Callout/Warning/WarningCallout";
@ -36,8 +39,9 @@ import html from 'highlight.js/lib/languages/xml'
import python from 'highlight.js/lib/languages/python'
import java from 'highlight.js/lib/languages/java'
import { CourseProvider } from "@components/Contexts/CourseContext";
import { OrgProvider } from "@components/Contexts/OrgContext";
import { useSession } from "@components/Contexts/SessionContext";
import AIEditorToolkit from "./AI/AIEditorToolkit";
import useGetAIFeatures from "@components/AI/Hooks/useGetAIFeatures";
interface Editor {
@ -52,6 +56,17 @@ interface Editor {
function Editor(props: Editor) {
const session = useSession() as any;
const dispatchAIEditor = useAIEditorDispatch() as any;
const aiEditorState = useAIEditor() as AIEditorStateTypes;
const is_ai_feature_enabled = useGetAIFeatures({ feature: 'editor' });
const [isButtonAvailable, setIsButtonAvailable] = React.useState(false);
React.useEffect(() => {
if (is_ai_feature_enabled) {
setIsButtonAvailable(true);
}
}, [is_ai_feature_enabled])
// remove course_ from course_uuid
const course_uuid = props.course.course_uuid.substring(7);
@ -129,7 +144,6 @@ function Editor(props: Editor) {
return (
<Page>
<OrgProvider orgslug={props.org?.slug}>
<CourseProvider courseuuid={props.course.course_uuid}>
<motion.div
initial={{ opacity: 0, scale: 0.98 }}
@ -156,19 +170,30 @@ function Editor(props: Editor) {
{" "}
<b>{props.course.name}</b> <SlashIcon /> {props.activity.name}{" "}
</EditorInfoDocName>
</EditorInfoWrapper>
<EditorButtonsWrapper>
<ToolbarButtons editor={editor} />
</EditorButtonsWrapper>
</EditorDocSection>
<EditorUsersSection>
<EditorUserProfileWrapper>
{!session.isAuthenticated && <span>Loading</span>}
{session.isAuthenticated && <Avvvatars value={session.user.user_uuid} style="shape" />}
</EditorUserProfileWrapper>
<EditorUsersSection className="space-x-2">
<div>
<div className="transition-all ease-linear text-teal-100 rounded-md hover:cursor-pointer" >
{isButtonAvailable && <div
onClick={() => dispatchAIEditor({ type: aiEditorState.isModalOpen ? 'setIsModalClose' : 'setIsModalOpen' })}
style={{
background: 'conic-gradient(from 32deg at 53.75% 50%, rgb(35, 40, 93) 4deg, rgba(20, 0, 52, 0.95) 59deg, rgba(164, 45, 238, 0.88) 281deg)',
}}
className="rounded-md px-3 py-2 drop-shadow-md flex items-center space-x-1.5 text-sm text-white hover:cursor-pointer transition delay-150 duration-300 ease-in-out hover:scale-105">
{" "}
<i>
<Image className='' width={20} src={learnhouseAI_icon} alt="" />
</i>{" "}
<i className="not-italic text-xs font-bold">AI Editor</i>
</div>}
</div>
</div>
<DividerVerticalIcon style={{ marginTop: "auto", marginBottom: "auto", color: "grey", opacity: '0.5' }} />
<EditorLeftOptionsSection className="space-x-2 pl-2 pr-3">
<EditorLeftOptionsSection className="space-x-2 ">
<div className="bg-sky-600 hover:bg-sky-700 transition-all ease-linear px-3 py-2 font-black text-sm shadow text-teal-100 rounded-lg hover:cursor-pointer" onClick={() => props.setContent(editor.getJSON())}> Save </div>
<ToolTip content="Preview">
<Link target="_blank" href={`/course/${course_uuid}/activity/${activity_uuid}`}>
@ -178,6 +203,13 @@ function Editor(props: Editor) {
</Link>
</ToolTip>
</EditorLeftOptionsSection>
<DividerVerticalIcon style={{ marginTop: "auto", marginBottom: "auto", color: "grey", opacity: '0.5' }} />
<EditorUserProfileWrapper>
{!session.isAuthenticated && <span>Loading</span>}
{session.isAuthenticated && <Avvvatars value={session.user.user_uuid} style="shape" />}
</EditorUserProfileWrapper>
</EditorUsersSection>
</EditorTop>
</motion.div>
@ -193,11 +225,11 @@ function Editor(props: Editor) {
exit={{ opacity: 0 }}
>
<EditorContentWrapper>
<AIEditorToolkit activity={props.activity} editor={editor} />
<EditorContent editor={editor} />
</EditorContentWrapper>
</motion.div>
</CourseProvider>
</OrgProvider>
</Page>
);
}

View file

@ -5,6 +5,7 @@ import Editor from "./Editor";
import { updateActivity } from "@services/courses/activities";
import { toast } from "react-hot-toast";
import Toast from "@components/StyledElements/Toast/Toast";
import { OrgProvider } from "@components/Contexts/OrgContext";
interface EditorWrapperProps {
content: string;
@ -26,7 +27,7 @@ function EditorWrapper(props: EditorWrapperProps): JSX.Element {
// setProviderState(provider);
setIsLoading(false);
}
@ -50,8 +51,9 @@ function EditorWrapper(props: EditorWrapperProps): JSX.Element {
} else {
return <>
<Toast></Toast>
<Editor org={props.org} course={props.course} activity={props.activity} content={props.content} setContent={setContent} provider={providerState} ydoc={ydocState}></Editor>;
<OrgProvider orgslug={props.org.slug}>
<Editor org={props.org} course={props.course} activity={props.activity} content={props.content} setContent={setContent} provider={providerState} ydoc={ydocState}></Editor>;
</OrgProvider>
</>
}
}

View file

@ -1,13 +1,17 @@
import { useEditorProvider } from "@components/Contexts/Editor/EditorContext";
import { NodeViewContent, NodeViewWrapper } from "@tiptap/react";
import { AlertCircle } from "lucide-react";
import React from "react";
import styled from "styled-components";
function InfoCalloutComponent(props: any) {
const editorState = useEditorProvider() as any;
const isEditable = editorState.isEditable;
return (
<NodeViewWrapper>
<InfoCalloutWrapper className="flex space-x-2 items-center bg-blue-200 rounded-lg text-blue-900 px-3 shadow-inner" contentEditable={props.extension.options.editable}>
<AlertCircle /> <NodeViewContent contentEditable={props.extension.options.editable} className="content" />
<InfoCalloutWrapper className="flex space-x-2 items-center bg-blue-200 rounded-lg text-blue-900 px-3 shadow-inner" contentEditable={isEditable}>
<AlertCircle /> <NodeViewContent contentEditable={isEditable} className="content" />
</InfoCalloutWrapper>
</NodeViewWrapper>
);

View file

@ -8,8 +8,6 @@ export default Node.create({
group: "block",
draggable: true,
content: "text*",
marks: "",
defining: true,
// TODO : multi line support

View file

@ -1,13 +1,17 @@
import { useEditorProvider } from "@components/Contexts/Editor/EditorContext";
import { NodeViewContent, NodeViewWrapper } from "@tiptap/react";
import { AlertTriangle } from "lucide-react";
import React from "react";
import styled from "styled-components";
function WarningCalloutComponent(props: any) {
const editorState = useEditorProvider() as any;
const isEditable = editorState.isEditable;
return (
<NodeViewWrapper>
<CalloutWrapper className="flex space-x-2 items-center bg-yellow-200 rounded-lg text-yellow-900 px-3 shadow-inner" contentEditable={props.extension.options.editable}>
<AlertTriangle /> <NodeViewContent contentEditable={props.extension.options.editable} className="content" />
<CalloutWrapper className="flex space-x-2 items-center bg-yellow-200 rounded-lg text-yellow-900 px-3 shadow-inner" contentEditable={isEditable}>
<AlertTriangle /> <NodeViewContent contentEditable={isEditable} className="content" />
</CalloutWrapper>
</NodeViewWrapper>
);

View file

@ -8,10 +8,14 @@ import { UploadIcon } from "@radix-ui/react-icons";
import { getActivityBlockMediaDirectory } from "@services/media/media";
import { useOrg } from "@components/Contexts/OrgContext";
import { useCourse } from "@components/Contexts/CourseContext";
import { useEditorProvider } from "@components/Contexts/Editor/EditorContext";
function ImageBlockComponent(props: any) {
const org = useOrg() as any;
const course = useCourse() as any;
const editorState = useEditorProvider() as any;
const isEditable = editorState.isEditable;
const [image, setImage] = React.useState(null);
const [isLoading, setIsLoading] = React.useState(false);
const [blockObject, setblockObject] = React.useState(props.node.attrs.blockObject);
@ -39,8 +43,8 @@ function ImageBlockComponent(props: any) {
return (
<NodeViewWrapper className="block-image">
{!blockObject && props.extension.options.editable && (
<BlockImageWrapper className="flex items-center space-x-3 py-7 bg-gray-50 rounded-xl text-gray-900 px-3 border-dashed border-gray-150 border-2" contentEditable={props.extension.options.editable}>
{!blockObject && isEditable && (
<BlockImageWrapper className="flex items-center space-x-3 py-7 bg-gray-50 rounded-xl text-gray-900 px-3 border-dashed border-gray-150 border-2" contentEditable={isEditable}>
{isLoading ? (
<Loader className="animate-spin animate-pulse text-gray-200" size={50} />
) : (

View file

@ -5,11 +5,13 @@ import "katex/dist/katex.min.css";
import { InlineMath, BlockMath } from "react-katex";
import { Edit, Save } from "lucide-react";
import Link from "next/link";
import { useEditorProvider } from "@components/Contexts/Editor/EditorContext";
function MathEquationBlockComponent(props: any) {
const [equation, setEquation] = React.useState(props.node.attrs.math_equation);
const [isEditing, setIsEditing] = React.useState(true);
const isEditable = props.extension.options.editable;
const editorState = useEditorProvider() as any;
const isEditable = editorState.isEditable;
const handleEquationChange = (event: React.ChangeEvent<any>) => {
setEquation(event.target.value);

View file

@ -0,0 +1,21 @@
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from 'prosemirror-state';
export const NoTextInput = Extension.create({
name: 'noTextInput',
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('noTextInput'),
filterTransaction: (transaction) => {
// If the transaction is adding text, stop it
return !transaction.docChanged || transaction.steps.every((step) => {
const { slice } = step.toJSON();
return !slice || !slice.content.some((node: { type: string; }) => node.type === 'text');
});
},
}),
];
},
});

View file

@ -8,6 +8,7 @@ import { UploadIcon } from "@radix-ui/react-icons";
import { getActivityBlockMediaDirectory } from "@services/media/media";
import { useOrg } from "@components/Contexts/OrgContext";
import { useCourse } from "@components/Contexts/CourseContext";
import { useEditorProvider } from "@components/Contexts/Editor/EditorContext";
function PDFBlockComponent(props: any) {
const org = useOrg() as any;
@ -16,6 +17,8 @@ function PDFBlockComponent(props: any) {
const [isLoading, setIsLoading] = React.useState(false);
const [blockObject, setblockObject] = React.useState(props.node.attrs.blockObject);
const fileId = blockObject ? `${blockObject.content.file_id}.${blockObject.content.file_format}` : null;
const editorState = useEditorProvider() as any;
const isEditable = editorState.isEditable;
const handlePDFChange = (event: React.ChangeEvent<any>) => {
setPDF(event.target.files[0]);
@ -39,7 +42,7 @@ function PDFBlockComponent(props: any) {
return (
<NodeViewWrapper className="block-pdf">
{!blockObject && (
<BlockPDFWrapper className="flex items-center space-x-3 py-7 bg-gray-50 rounded-xl text-gray-900 px-3 border-dashed border-gray-150 border-2" contentEditable={props.extension.options.editable}>
<BlockPDFWrapper className="flex items-center space-x-3 py-7 bg-gray-50 rounded-xl text-gray-900 px-3 border-dashed border-gray-150 border-2" contentEditable={isEditable}>
{isLoading ? (
<Loader className="animate-spin animate-pulse text-gray-200" size={50} />
) : (

View file

@ -4,6 +4,7 @@ import { twJoin, twMerge } from 'tailwind-merge'
import React from "react";
import { BadgeHelp, Check, Info, Minus, MoreVertical, Plus, RefreshCcw, X } from "lucide-react";
import ReactConfetti from "react-confetti";
import { useEditorProvider } from "@components/Contexts/Editor/EditorContext";
interface Answer {
answer_id: string;
@ -22,7 +23,8 @@ function QuizBlockComponent(props: any) {
const [userAnswers, setUserAnswers] = React.useState([]) as [any[], any];
const [submitted, setSubmitted] = React.useState(false) as [boolean, any];
const [submissionMessage, setSubmissionMessage] = React.useState("") as [string, any];
const isEditable = props.extension.options.editable;
const editorState = useEditorProvider() as any;
const isEditable = editorState.isEditable;
const handleAnswerClick = (question_id: string, answer_id: string) => {
// if the quiz is submitted, do nothing

View file

@ -8,10 +8,13 @@ import { getActivityBlockMediaDirectory } from "@services/media/media";
import { UploadIcon } from "@radix-ui/react-icons";
import { useOrg } from "@components/Contexts/OrgContext";
import { useCourse } from "@components/Contexts/CourseContext";
import { useEditorProvider } from "@components/Contexts/Editor/EditorContext";
function VideoBlockComponents(props: any) {
const org = useOrg() as any;
const course = useCourse() as any;
const editorState = useEditorProvider() as any;
const isEditable = editorState.isEditable;
const [video, setVideo] = React.useState(null);
const [isLoading, setIsLoading] = React.useState(false);
const [blockObject, setblockObject] = React.useState(props.node.attrs.blockObject);
@ -39,7 +42,7 @@ function VideoBlockComponents(props: any) {
return (
<NodeViewWrapper className="block-video">
{!blockObject && (
<BlockVideoWrapper className="flex items-center space-x-3 py-7 bg-gray-50 rounded-xl text-gray-900 px-3 border-dashed border-gray-150 border-2" contentEditable={props.extension.options.editable}>
<BlockVideoWrapper className="flex items-center space-x-3 py-7 bg-gray-50 rounded-xl text-gray-900 px-3 border-dashed border-gray-150 border-2" contentEditable={isEditable}>
{isLoading ? (
<Loader className="animate-spin animate-pulse text-gray-200" size={50} />
) : (

View file

@ -1,15 +1,15 @@
import styled from "styled-components";
import { FontBoldIcon, FontItalicIcon, StrikethroughIcon, ArrowLeftIcon, ArrowRightIcon, OpacityIcon, DividerVerticalIcon, ListBulletIcon } from "@radix-ui/react-icons";
import { AlertCircle, AlertTriangle, BadgeHelp, Code, FileText, GraduationCap, HelpCircle, ImagePlus, Info, ListChecks, Sigma, Video, Youtube } from "lucide-react";
import { FontBoldIcon, FontItalicIcon, StrikethroughIcon, ArrowLeftIcon, ArrowRightIcon, DividerVerticalIcon, ListBulletIcon } from "@radix-ui/react-icons";
import { AlertCircle, AlertTriangle, BadgeHelp, Code, FileText, ImagePlus, Sigma, Video, Youtube } from "lucide-react";
import ToolTip from "@components/StyledElements/Tooltip/Tooltip";
export const ToolbarButtons = ({ editor, props }: any) => {
if (!editor) {
return null;
}
// YouTube extension
const addYoutubeVideo = () => {
const url = prompt("Enter YouTube URL");

View file

@ -1,5 +1,5 @@
'use client';
import React, { use, useEffect } from "react";
import React, { useEffect } from "react";
import Link from "next/link";
import { getAPIUrl, getUriWithOrg } from "@services/config/config";
import { getOrganizationContextInfo, getOrganizationContextInfoWithoutCredentials } from "@services/organizations/orgs";

View file

@ -28,7 +28,7 @@
"formik": "^2.2.9",
"framer-motion": "^10.16.1",
"lowlight": "^3.0.0",
"lucide-react": "^0.268.0",
"lucide-react": "^0.307.0",
"next": "14.0.4",
"re-resizable": "^6.9.9",
"react": "^18.2.0",
@ -42,6 +42,7 @@
"styled-components": "^6.0.0-beta.9",
"swr": "^2.2.4",
"tailwind-merge": "^1.14.0",
"tailwind-scrollbar": "^3.0.5",
"uuid": "^9.0.0",
"y-indexeddb": "^9.0.9",
"y-webrtc": "^10.2.3",
@ -77,7 +78,6 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
"dev": true,
"engines": {
"node": ">=10"
},
@ -2446,7 +2446,6 @@
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9"
@ -2459,7 +2458,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"engines": {
"node": ">= 8"
}
@ -2468,7 +2466,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
"fastq": "^1.6.0"
@ -4263,14 +4260,12 @@
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
"dev": true
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"devOptional": true,
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
@ -4282,8 +4277,7 @@
"node_modules/arg": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
"dev": true
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="
},
"node_modules/argparse": {
"version": "2.0.1",
@ -4606,7 +4600,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
"integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
"devOptional": true,
"engines": {
"node": ">=8"
}
@ -4624,7 +4617,6 @@
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
"devOptional": true,
"dependencies": {
"fill-range": "^7.0.1"
},
@ -4723,7 +4715,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
"dev": true,
"engines": {
"node": ">= 6"
}
@ -4783,7 +4774,6 @@
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
"integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
"devOptional": true,
"funding": [
{
"type": "individual",
@ -4810,7 +4800,6 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"devOptional": true,
"dependencies": {
"is-glob": "^4.0.1"
},
@ -4935,7 +4924,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"dev": true,
"bin": {
"cssesc": "bin/cssesc"
},
@ -5049,8 +5037,7 @@
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
"dev": true
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="
},
"node_modules/dir-glob": {
"version": "3.0.1",
@ -5067,8 +5054,7 @@
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"dev": true
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="
},
"node_modules/doctrine": {
"version": "3.0.0",
@ -5704,7 +5690,6 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
"integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==",
"dev": true,
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
@ -5720,7 +5705,6 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"dependencies": {
"is-glob": "^4.0.1"
},
@ -5744,7 +5728,6 @@
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz",
"integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==",
"dev": true,
"dependencies": {
"reusify": "^1.0.4"
}
@ -5765,7 +5748,6 @@
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
"devOptional": true,
"dependencies": {
"to-regex-range": "^5.0.1"
},
@ -6023,7 +6005,6 @@
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
"dependencies": {
"is-glob": "^4.0.3"
},
@ -6379,7 +6360,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"devOptional": true,
"dependencies": {
"binary-extensions": "^2.0.0"
},
@ -6457,7 +6437,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"devOptional": true,
"engines": {
"node": ">=0.10.0"
}
@ -6493,7 +6472,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"devOptional": true,
"dependencies": {
"is-extglob": "^2.1.1"
},
@ -6526,7 +6504,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"devOptional": true,
"engines": {
"node": ">=0.12.0"
}
@ -6737,7 +6714,6 @@
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.20.0.tgz",
"integrity": "sha512-3TV69ZbrvV6U5DfQimop50jE9Dl6J8O1ja1dvBbMba/sZ3YBEQqJ2VZRoQPVnhlzjNtU1vaXRZVrVjU4qtm8yA==",
"dev": true,
"bin": {
"jiti": "bin/jiti.js"
}
@ -6897,7 +6873,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
"integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==",
"dev": true,
"engines": {
"node": ">=10"
}
@ -6905,8 +6880,7 @@
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"dev": true
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="
},
"node_modules/linkify-it": {
"version": "4.0.1",
@ -7004,9 +6978,9 @@
}
},
"node_modules/lucide-react": {
"version": "0.268.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.268.0.tgz",
"integrity": "sha512-XP/xY3ASJAViqNqVnDRcEfdxfRB7uNST8sqTLwZhL983ikmHMQ7qQak7ZxrnXOVhB3QDBawdr3ANq0P+iWHP/g==",
"version": "0.307.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.307.0.tgz",
"integrity": "sha512-+vZ+vUiWPZTMnLHURg4aoIaz6NHOWXVVcVd8iLROu1k4LbyjcnHIKmbjXHCmulz7XAYLWRVXzhJJgIr+Aq3vOg==",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
}
@ -7079,7 +7053,6 @@
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"engines": {
"node": ">= 8"
}
@ -7088,7 +7061,6 @@
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
"integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
"dev": true,
"dependencies": {
"braces": "^3.0.2",
"picomatch": "^2.3.1"
@ -7136,7 +7108,6 @@
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
"dev": true,
"dependencies": {
"any-promise": "^1.0.0",
"object-assign": "^4.0.1",
@ -7240,7 +7211,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"devOptional": true,
"engines": {
"node": ">=0.10.0"
}
@ -7266,7 +7236,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"dev": true,
"engines": {
"node": ">= 6"
}
@ -7545,7 +7514,6 @@
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz",
"integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==",
"dev": true,
"engines": {
"node": ">= 6"
}
@ -7581,7 +7549,6 @@
"version": "15.1.0",
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
"integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
"dev": true,
"dependencies": {
"postcss-value-parser": "^4.0.0",
"read-cache": "^1.0.0",
@ -7598,7 +7565,6 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz",
"integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==",
"dev": true,
"dependencies": {
"camelcase-css": "^2.0.1"
},
@ -7617,7 +7583,6 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.1.tgz",
"integrity": "sha512-vEJIc8RdiBRu3oRAI0ymerOn+7rPuMvRXslTvZUKZonDHFIczxztIyJ1urxM1x9JXEikvpWWTUUqal5j/8QgvA==",
"dev": true,
"dependencies": {
"lilconfig": "^2.0.5",
"yaml": "^2.1.1"
@ -7646,7 +7611,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz",
"integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==",
"dev": true,
"dependencies": {
"postcss-selector-parser": "^6.0.11"
},
@ -7665,7 +7629,6 @@
"version": "6.0.13",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz",
"integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==",
"dev": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@ -8173,7 +8136,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
"dev": true,
"dependencies": {
"pify": "^2.3.0"
}
@ -8182,7 +8144,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
@ -8204,7 +8165,6 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"devOptional": true,
"dependencies": {
"picomatch": "^2.2.1"
},
@ -8359,7 +8319,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
"integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
"dev": true,
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
@ -8424,7 +8383,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [
{
"type": "github",
@ -8841,7 +8799,6 @@
"version": "3.34.0",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz",
"integrity": "sha512-70/LQEZ07TEcxiU2dz51FKaE6hCTWC6vr7FOk3Gr0U60C3shtAN+H+BFr9XlYe5xqf3RA8nrc+VIwzCfnxuXJw==",
"dev": true,
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.2",
"commander": "^4.0.0",
@ -8863,7 +8820,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
"dev": true,
"engines": {
"node": ">= 6"
}
@ -8872,7 +8828,6 @@
"version": "7.1.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
"dev": true,
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
@ -8931,11 +8886,21 @@
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwind-scrollbar": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/tailwind-scrollbar/-/tailwind-scrollbar-3.0.5.tgz",
"integrity": "sha512-0ZwxTivevqq9BY9fRP9zDjHl7Tu+J5giBGbln+0O1R/7nHtBUKnjQcA1aTIhK7Oyjp6Uc/Dj6/dn8Dq58k5Uww==",
"engines": {
"node": ">=12.13.0"
},
"peerDependencies": {
"tailwindcss": "3.x"
}
},
"node_modules/tailwindcss": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.3.tgz",
"integrity": "sha512-A0KgSkef7eE4Mf+nKJ83i75TMyq8HqY3qmFIJSWy8bNt0v1lG7jUcpGpoTFxAwYcWOphcTBLPPJg+bDfhDf52w==",
"dev": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@ -8987,7 +8952,6 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
"dev": true,
"dependencies": {
"any-promise": "^1.0.0"
}
@ -8996,7 +8960,6 @@
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
"dev": true,
"dependencies": {
"thenify": ">= 3.1.0 < 4"
},
@ -9043,7 +9006,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"devOptional": true,
"dependencies": {
"is-number": "^7.0.0"
},
@ -9071,8 +9033,7 @@
"node_modules/ts-interface-checker": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="
},
"node_modules/tsconfig-paths": {
"version": "3.14.2",
@ -9622,7 +9583,6 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.2.tgz",
"integrity": "sha512-N/lyzTPaJasoDmfV7YTrYCI0G/3ivm/9wdG0aHuheKowWQwGTsK0Eoiw6utmzAnI6pkJa0DUVygvp3spqqEKXg==",
"dev": true,
"engines": {
"node": ">= 14"
}

View file

@ -29,7 +29,7 @@
"formik": "^2.2.9",
"framer-motion": "^10.16.1",
"lowlight": "^3.0.0",
"lucide-react": "^0.268.0",
"lucide-react": "^0.307.0",
"next": "14.0.4",
"re-resizable": "^6.9.9",
"react": "^18.2.0",
@ -43,6 +43,7 @@
"styled-components": "^6.0.0-beta.9",
"swr": "^2.2.4",
"tailwind-merge": "^1.14.0",
"tailwind-scrollbar": "^3.0.5",
"uuid": "^9.0.0",
"y-indexeddb": "^9.0.9",
"y-webrtc": "^10.2.3",

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View file

@ -0,0 +1,32 @@
import { getAPIUrl } from "@services/config/config";
import { RequestBody, RequestBodyWithAuthHeader } from "@services/utils/ts/requests";
export async function startActivityAIChatSession(message: string, activity_uuid: string) {
const data = {
message,
activity_uuid,
};
const result = await fetch(`${getAPIUrl()}ai/start/activity_chat_session`, RequestBody("POST", data, null));
const json = await result.json();
if (result.status === 200) {
return { success: true, data: json, status: result.status, HTTPmessage: result.statusText };
} else {
return { success: false, data: json, status: result.status, HTTPmessage: result.statusText };
}
}
export async function sendActivityAIChatMessage(message: string, aichat_uuid: string, activity_uuid: string) {
const data = {
aichat_uuid,
message,
activity_uuid,
};
const result = await fetch(`${getAPIUrl()}ai/send/activity_chat_message`, RequestBody("POST", data, null));
const json = await result.json();
if (result.status === 200) {
return { success: true, data: json, status: result.status, HTTPmessage: result.statusText };
} else {
return { success: false, data: json, status: result.status, HTTPmessage: result.statusText };
}
}

View file

@ -7,6 +7,8 @@ module.exports = {
theme: {
extend: {},
},
plugins: [],
plugins: [
require('tailwind-scrollbar')({ nocompatible: true }),
],
}

View file

@ -17,6 +17,11 @@ services:
- POSTGRES_USER=learnhouse
- POSTGRES_PASSWORD=learnhouse
- POSTGRES_DB=learnhouse
redis:
image: redis:7.2.3
restart: always
ports:
- "6379:6379"
mongo:
image: mongo:5.0
restart: always

190
pnpm-lock.yaml generated
View file

@ -42,8 +42,8 @@ importers:
specifier: ^1.0.5
version: 1.0.7(@types/react-dom@18.0.6)(@types/react@18.2.8)(react-dom@18.2.0)(react@18.2.0)
'@sentry/nextjs':
specifier: ^7.88.0
version: 7.88.0(next@14.0.4)(react@18.2.0)
specifier: ^7.92.0
version: 7.93.0(next@14.0.4)(react@18.2.0)
'@stitches/react':
specifier: ^1.2.8
version: 1.2.8(react@18.2.0)
@ -81,8 +81,8 @@ importers:
specifier: ^3.0.0
version: 3.1.0
lucide-react:
specifier: ^0.268.0
version: 0.268.0(react@18.2.0)
specifier: ^0.307.0
version: 0.307.0(react@18.2.0)
next:
specifier: 14.0.4
version: 14.0.4(@babel/core@7.23.2)(react-dom@18.2.0)(react@18.2.0)
@ -122,6 +122,9 @@ importers:
tailwind-merge:
specifier: ^1.14.0
version: 1.14.0
tailwind-scrollbar:
specifier: ^3.0.5
version: 3.0.5(tailwindcss@3.3.3)
uuid:
specifier: ^9.0.0
version: 9.0.1
@ -188,7 +191,6 @@ packages:
/@alloc/quick-lru@5.2.0:
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
dev: true
/@ampproject/remapping@2.2.1:
resolution: {integrity: sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==}
@ -1756,12 +1758,10 @@ packages:
dependencies:
'@nodelib/fs.stat': 2.0.5
run-parallel: 1.2.0
dev: true
/@nodelib/fs.stat@2.0.5:
resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
engines: {node: '>= 8'}
dev: true
/@nodelib/fs.walk@1.2.8:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
@ -1769,7 +1769,6 @@ packages:
dependencies:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.15.0
dev: true
/@popperjs/core@2.11.8:
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
@ -2430,34 +2429,34 @@ packages:
resolution: {integrity: sha512-6i/8UoL0P5y4leBIGzvkZdS85RDMG9y1ihZzmTZQ5LdHUYmZ7pKFoj8X0236s3lusPs1Fa5HTQUpwI+UfTcmeA==}
dev: true
/@sentry-internal/feedback@7.88.0:
resolution: {integrity: sha512-lbK6jgO1I0M96nZQ99mcLSZ55ebwPAP6LhEWhkmc+eAfy97VpiY+qsbmgsmOzCEPqMmEUCEcI0rEZ7fiye2v2Q==}
/@sentry-internal/feedback@7.93.0:
resolution: {integrity: sha512-4G7rMeQbYGfCHxEoFroABX+UREYc2BSbFqjLmLbIcWowSpgzcwweLLphWHKOciqK6f7DnNDK0jZzx3u7NrkWHw==}
engines: {node: '>=12'}
dependencies:
'@sentry/core': 7.88.0
'@sentry/types': 7.88.0
'@sentry/utils': 7.88.0
'@sentry/core': 7.93.0
'@sentry/types': 7.93.0
'@sentry/utils': 7.93.0
dev: false
/@sentry-internal/tracing@7.88.0:
resolution: {integrity: sha512-xXQdcYhsS+ourzJHjXNjZC9zakuc97udmpgaXRjEP7FjPYclIx+YXwgFBdHM2kzAwZLFOsEce5dr46GVXUDfZw==}
/@sentry-internal/tracing@7.93.0:
resolution: {integrity: sha512-DjuhmQNywPp+8fxC9dvhGrqgsUb6wI/HQp25lS2Re7VxL1swCasvpkg8EOYP4iBniVQ86QK0uITkOIRc5tdY1w==}
engines: {node: '>=8'}
dependencies:
'@sentry/core': 7.88.0
'@sentry/types': 7.88.0
'@sentry/utils': 7.88.0
'@sentry/core': 7.93.0
'@sentry/types': 7.93.0
'@sentry/utils': 7.93.0
dev: false
/@sentry/browser@7.88.0:
resolution: {integrity: sha512-il4x3PB99nuU/OJQw2RltgYYbo8vtnYoIgneOeEiw4m0ppK1nKkMkd3vDRipGL6E/0i7IUmQfYYy6U10J5Rx+g==}
/@sentry/browser@7.93.0:
resolution: {integrity: sha512-MtLTcQ7y3rfk+aIvnnwCfSJvYhTJnIJi+Mf6y/ap6SKObdlsKMbQoJLlRViglGLq+nKxHLAvU0fONiCEmKfV6A==}
engines: {node: '>=8'}
dependencies:
'@sentry-internal/feedback': 7.88.0
'@sentry-internal/tracing': 7.88.0
'@sentry/core': 7.88.0
'@sentry/replay': 7.88.0
'@sentry/types': 7.88.0
'@sentry/utils': 7.88.0
'@sentry-internal/feedback': 7.93.0
'@sentry-internal/tracing': 7.93.0
'@sentry/core': 7.93.0
'@sentry/replay': 7.93.0
'@sentry/types': 7.93.0
'@sentry/utils': 7.93.0
dev: false
/@sentry/cli@1.77.1:
@ -2477,26 +2476,26 @@ packages:
- supports-color
dev: false
/@sentry/core@7.88.0:
resolution: {integrity: sha512-Jzbb7dcwiCO7kI0a1w+32UzWxbEn2OcZWzp55QMEeAh6nZ/5CXhXwpuHi0tW7doPj+cJdmxMTMu9LqMVfdGkzQ==}
/@sentry/core@7.93.0:
resolution: {integrity: sha512-vZQSUiDn73n+yu2fEcH+Wpm4GbRmtxmnXnYCPgM6IjnXqkVm3awWAkzrheADblx3kmxrRiOlTXYHw9NTWs56fg==}
engines: {node: '>=8'}
dependencies:
'@sentry/types': 7.88.0
'@sentry/utils': 7.88.0
'@sentry/types': 7.93.0
'@sentry/utils': 7.93.0
dev: false
/@sentry/integrations@7.88.0:
resolution: {integrity: sha512-YBYPAtJeylMaaCmGntgiDpp1nk3IT6+FBXsmHxMdTKlrpt5ELj/jcc8gEgaRNeSBjx4Kv1OVzmZcYyWwEhkR4Q==}
/@sentry/integrations@7.93.0:
resolution: {integrity: sha512-uGQ8+DiqUr6SbhdJJHyIqDJ6kHnFuSv8nZWtj2tJ1I8q8u8MX8t8Om6R/R4ap45gCkWg/zqZq7B+gQV6TYewjQ==}
engines: {node: '>=8'}
dependencies:
'@sentry/core': 7.88.0
'@sentry/types': 7.88.0
'@sentry/utils': 7.88.0
'@sentry/core': 7.93.0
'@sentry/types': 7.93.0
'@sentry/utils': 7.93.0
localforage: 1.10.0
dev: false
/@sentry/nextjs@7.88.0(next@14.0.4)(react@18.2.0):
resolution: {integrity: sha512-tvP9KU7SeL65szenGoexABdPqCVMUTWEP9DroNvBDvTtqfETOf8RbGw8zE+bFNxQ9bjAzhJPibu6oWNcpYvXMA==}
/@sentry/nextjs@7.93.0(next@14.0.4)(react@18.2.0):
resolution: {integrity: sha512-/O4Xl+hMSEM6/sVfmKXCZhLUUGNJbi+L0tasTiw4wB4EQQeMDKf4cBfx8e4mNBMzhA2SZnfQZAwJGqhvFJniPQ==}
engines: {node: '>=8'}
peerDependencies:
next: ^10.0.8 || ^11.0 || ^12.0 || ^13.0 || ^14.0
@ -2507,13 +2506,13 @@ packages:
optional: true
dependencies:
'@rollup/plugin-commonjs': 24.0.0(rollup@2.78.0)
'@sentry/core': 7.88.0
'@sentry/integrations': 7.88.0
'@sentry/node': 7.88.0
'@sentry/react': 7.88.0(react@18.2.0)
'@sentry/types': 7.88.0
'@sentry/utils': 7.88.0
'@sentry/vercel-edge': 7.88.0
'@sentry/core': 7.93.0
'@sentry/integrations': 7.93.0
'@sentry/node': 7.93.0
'@sentry/react': 7.93.0(react@18.2.0)
'@sentry/types': 7.93.0
'@sentry/utils': 7.93.0
'@sentry/vercel-edge': 7.93.0
'@sentry/webpack-plugin': 1.21.0
chalk: 3.0.0
next: 14.0.4(@babel/core@7.23.2)(react-dom@18.2.0)(react@18.2.0)
@ -2526,62 +2525,63 @@ packages:
- supports-color
dev: false
/@sentry/node@7.88.0:
resolution: {integrity: sha512-X6Xyh7AEitnWqn1CHQrmsUqRn0GKj/6nPE5VC2DLQfHiFH1Fknrt+csFzDchQ/86awXYwuY4Le5ECEH//X/WzQ==}
/@sentry/node@7.93.0:
resolution: {integrity: sha512-nUXPCZQm5Y9Ipv7iWXLNp5dbuyi1VvbJ3RtlwD7utgsNkRYB4ixtKE9w2QU8DZZAjaEF6w2X94OkYH6C932FWw==}
engines: {node: '>=8'}
dependencies:
'@sentry-internal/tracing': 7.88.0
'@sentry/core': 7.88.0
'@sentry/types': 7.88.0
'@sentry/utils': 7.88.0
'@sentry-internal/tracing': 7.93.0
'@sentry/core': 7.93.0
'@sentry/types': 7.93.0
'@sentry/utils': 7.93.0
https-proxy-agent: 5.0.1
transitivePeerDependencies:
- supports-color
dev: false
/@sentry/react@7.88.0(react@18.2.0):
resolution: {integrity: sha512-iDOImijbsc0cYLWNBXlYKhh/sG/czPK/51GcMi3GcEBkhHDDcdWSZ7cNjFAqHfdrMkPf26bYgDPIL6aJsBZwpQ==}
/@sentry/react@7.93.0(react@18.2.0):
resolution: {integrity: sha512-B0bzziV1lEyN7xd0orUPyJdpoK6CtcyodmQkfY0WsHLm/1d9xi95M05lObHnsMWO1js6c9B9d9kO8RlKFz947A==}
engines: {node: '>=8'}
peerDependencies:
react: 15.x || 16.x || 17.x || 18.x
dependencies:
'@sentry/browser': 7.88.0
'@sentry/types': 7.88.0
'@sentry/utils': 7.88.0
'@sentry/browser': 7.93.0
'@sentry/core': 7.93.0
'@sentry/types': 7.93.0
'@sentry/utils': 7.93.0
hoist-non-react-statics: 3.3.2
react: 18.2.0
dev: false
/@sentry/replay@7.88.0:
resolution: {integrity: sha512-em5dPKLPG7c/HGDbpIj3aHrWbA4iMwqjevqTzn+++KNO1YslkOosCaGsb1whU3AL1T9c3aIFIhZ4u3rNo+DxcA==}
/@sentry/replay@7.93.0:
resolution: {integrity: sha512-dMlLU8v+OkUeGCrPvTu5NriH7BGj3el4rGHWWAYicfJ2QXqTTq50vfasQBP1JeVNcFqnf1y653TdEIvo4RH4tw==}
engines: {node: '>=12'}
dependencies:
'@sentry-internal/tracing': 7.88.0
'@sentry/core': 7.88.0
'@sentry/types': 7.88.0
'@sentry/utils': 7.88.0
'@sentry-internal/tracing': 7.93.0
'@sentry/core': 7.93.0
'@sentry/types': 7.93.0
'@sentry/utils': 7.93.0
dev: false
/@sentry/types@7.88.0:
resolution: {integrity: sha512-FvwvmX1pWAZKicPj4EpKyho8Wm+C4+r5LiepbbBF8oKwSPJdD2QV1fo/LWxsrzNxWOllFIVIXF5Ed3nPYQWpTw==}
/@sentry/types@7.93.0:
resolution: {integrity: sha512-UnzUccNakhFRA/esWBWP+0v7cjNg+RilFBQC03Mv9OEMaZaS29zSbcOGtRzuFOXXLBdbr44BWADqpz3VW0XaNw==}
engines: {node: '>=8'}
dev: false
/@sentry/utils@7.88.0:
resolution: {integrity: sha512-ukminfRmdBXTzk49orwJf3Lu3hR60ZRHjE2a4IXwYhyDT6JJgJqgsq1hzGXx0AyFfyS4WhfZ6QUBy7fu3BScZQ==}
/@sentry/utils@7.93.0:
resolution: {integrity: sha512-Iovj7tUnbgSkh/WrAaMrd5UuYjW7AzyzZlFDIUrwidsyIdUficjCG2OIxYzh76H6nYIx9SxewW0R54Q6XoB4uA==}
engines: {node: '>=8'}
dependencies:
'@sentry/types': 7.88.0
'@sentry/types': 7.93.0
dev: false
/@sentry/vercel-edge@7.88.0:
resolution: {integrity: sha512-PfaOiPPRw7y4CcOeP337NsPGyERpO6OlSAmLIaUkKJRjnGNmg1tSzUNfG0lg//fQ8XsZsXIun/ND+XXYtEJFDw==}
/@sentry/vercel-edge@7.93.0:
resolution: {integrity: sha512-3jddd6gVUpGX8Sis9gxODL7zPR+lZohYYvOJVhf8UMglZSiWa3/xYJQ5VISj3UH6sVSxvfMxgssmQEHcvuubHQ==}
engines: {node: '>=8'}
dependencies:
'@sentry-internal/tracing': 7.88.0
'@sentry/core': 7.88.0
'@sentry/types': 7.88.0
'@sentry/utils': 7.88.0
'@sentry-internal/tracing': 7.93.0
'@sentry/core': 7.93.0
'@sentry/types': 7.93.0
'@sentry/utils': 7.93.0
dev: false
/@sentry/webpack-plugin@1.21.0:
@ -3127,7 +3127,6 @@ packages:
/any-promise@1.3.0:
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
dev: true
/anymatch@3.1.3:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
@ -3138,7 +3137,6 @@ packages:
/arg@5.0.2:
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
dev: true
/argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@ -3392,7 +3390,6 @@ packages:
/camelcase-css@2.0.1:
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
engines: {node: '>= 6'}
dev: true
/camelize@1.0.1:
resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==}
@ -3535,7 +3532,6 @@ packages:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
hasBin: true
dev: true
/csstype@3.1.2:
resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==}
@ -3629,7 +3625,6 @@ packages:
/didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
dev: true
/dir-glob@3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
@ -3640,7 +3635,6 @@ packages:
/dlv@1.1.3:
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
dev: true
/doctrine@2.1.0:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
@ -4070,7 +4064,6 @@ packages:
glob-parent: 5.1.2
merge2: 1.4.1
micromatch: 4.0.5
dev: true
/fast-json-stable-stringify@2.1.0:
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
@ -4084,7 +4077,6 @@ packages:
resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==}
dependencies:
reusify: 1.0.4
dev: true
/file-entry-cache@6.0.1:
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
@ -4244,7 +4236,6 @@ packages:
engines: {node: '>=10.13.0'}
dependencies:
is-glob: 4.0.3
dev: true
/glob-to-regexp@0.4.1:
resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==}
@ -4259,7 +4250,6 @@ packages:
minimatch: 3.1.2
once: 1.4.0
path-is-absolute: 1.0.1
dev: true
/glob@7.1.7:
resolution: {integrity: sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==}
@ -4660,7 +4650,6 @@ packages:
/jiti@1.20.0:
resolution: {integrity: sha512-3TV69ZbrvV6U5DfQimop50jE9Dl6J8O1ja1dvBbMba/sZ3YBEQqJ2VZRoQPVnhlzjNtU1vaXRZVrVjU4qtm8yA==}
hasBin: true
dev: true
/js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@ -4766,11 +4755,9 @@ packages:
/lilconfig@2.1.0:
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
engines: {node: '>=10'}
dev: true
/lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
dev: true
/linkify-it@4.0.1:
resolution: {integrity: sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==}
@ -4838,8 +4825,8 @@ packages:
yallist: 4.0.0
dev: true
/lucide-react@0.268.0(react@18.2.0):
resolution: {integrity: sha512-XP/xY3ASJAViqNqVnDRcEfdxfRB7uNST8sqTLwZhL983ikmHMQ7qQak7ZxrnXOVhB3QDBawdr3ANq0P+iWHP/g==}
/lucide-react@0.307.0(react@18.2.0):
resolution: {integrity: sha512-+vZ+vUiWPZTMnLHURg4aoIaz6NHOWXVVcVd8iLROu1k4LbyjcnHIKmbjXHCmulz7XAYLWRVXzhJJgIr+Aq3vOg==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0
dependencies:
@ -4887,7 +4874,6 @@ packages:
/merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
dev: true
/micromatch@4.0.5:
resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==}
@ -4895,7 +4881,6 @@ packages:
dependencies:
braces: 3.0.2
picomatch: 2.3.1
dev: true
/minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
@ -4936,7 +4921,6 @@ packages:
any-promise: 1.3.0
object-assign: 4.1.1
thenify-all: 1.6.0
dev: true
/nanoid@3.3.6:
resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==}
@ -5018,7 +5002,6 @@ packages:
/object-hash@3.0.0:
resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
engines: {node: '>= 6'}
dev: true
/object-inspect@1.12.3:
resolution: {integrity: sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==}
@ -5170,7 +5153,6 @@ packages:
/pify@2.3.0:
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
engines: {node: '>=0.10.0'}
dev: true
/pify@4.0.1:
resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
@ -5180,7 +5162,6 @@ packages:
/pirates@4.0.6:
resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==}
engines: {node: '>= 6'}
dev: true
/postcss-import@15.1.0(postcss@8.4.31):
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
@ -5192,7 +5173,6 @@ packages:
postcss-value-parser: 4.2.0
read-cache: 1.0.0
resolve: 1.22.8
dev: true
/postcss-js@4.0.1(postcss@8.4.31):
resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==}
@ -5202,7 +5182,6 @@ packages:
dependencies:
camelcase-css: 2.0.1
postcss: 8.4.31
dev: true
/postcss-load-config@4.0.1(postcss@8.4.31):
resolution: {integrity: sha512-vEJIc8RdiBRu3oRAI0ymerOn+7rPuMvRXslTvZUKZonDHFIczxztIyJ1urxM1x9JXEikvpWWTUUqal5j/8QgvA==}
@ -5219,7 +5198,6 @@ packages:
lilconfig: 2.1.0
postcss: 8.4.31
yaml: 2.3.2
dev: true
/postcss-nested@6.0.1(postcss@8.4.31):
resolution: {integrity: sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==}
@ -5229,7 +5207,6 @@ packages:
dependencies:
postcss: 8.4.31
postcss-selector-parser: 6.0.13
dev: true
/postcss-selector-parser@6.0.13:
resolution: {integrity: sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==}
@ -5237,7 +5214,6 @@ packages:
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
dev: true
/postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
@ -5632,7 +5608,6 @@ packages:
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
dependencies:
pify: 2.3.0
dev: true
/readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
@ -5744,7 +5719,6 @@ packages:
/reusify@1.0.4:
resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
dev: true
/rimraf@3.0.2:
resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
@ -5769,7 +5743,6 @@ packages:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
dependencies:
queue-microtask: 1.2.3
dev: true
/safe-array-concat@1.0.1:
resolution: {integrity: sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==}
@ -6025,7 +5998,6 @@ packages:
mz: 2.7.0
pirates: 4.0.6
ts-interface-checker: 0.1.13
dev: true
/supports-color@5.5.0:
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
@ -6058,6 +6030,15 @@ packages:
resolution: {integrity: sha512-3mFKyCo/MBcgyOTlrY8T7odzZFx+w+qKSMAmdFzRvqBfLlSigU6TZnlFHK0lkMwj9Bj8OYU+9yW9lmGuS0QEnQ==}
dev: false
/tailwind-scrollbar@3.0.5(tailwindcss@3.3.3):
resolution: {integrity: sha512-0ZwxTivevqq9BY9fRP9zDjHl7Tu+J5giBGbln+0O1R/7nHtBUKnjQcA1aTIhK7Oyjp6Uc/Dj6/dn8Dq58k5Uww==}
engines: {node: '>=12.13.0'}
peerDependencies:
tailwindcss: 3.x
dependencies:
tailwindcss: 3.3.3
dev: false
/tailwindcss@3.3.3:
resolution: {integrity: sha512-A0KgSkef7eE4Mf+nKJ83i75TMyq8HqY3qmFIJSWy8bNt0v1lG7jUcpGpoTFxAwYcWOphcTBLPPJg+bDfhDf52w==}
engines: {node: '>=14.0.0'}
@ -6087,7 +6068,6 @@ packages:
sucrase: 3.34.0
transitivePeerDependencies:
- ts-node
dev: true
/tapable@2.2.1:
resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==}
@ -6103,13 +6083,11 @@ packages:
engines: {node: '>=0.8'}
dependencies:
thenify: 3.3.1
dev: true
/thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
dependencies:
any-promise: 1.3.0
dev: true
/throttle-debounce@3.0.1:
resolution: {integrity: sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==}
@ -6156,7 +6134,6 @@ packages:
/ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
dev: true
/tsconfig-paths@3.14.2:
resolution: {integrity: sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==}
@ -6573,7 +6550,6 @@ packages:
/yaml@2.3.2:
resolution: {integrity: sha512-N/lyzTPaJasoDmfV7YTrYCI0G/3ivm/9wdG0aHuheKowWQwGTsK0Eoiw6utmzAnI6pkJa0DUVygvp3spqqEKXg==}
engines: {node: '>= 14'}
dev: true
/yjs@13.6.8:
resolution: {integrity: sha512-ZPq0hpJQb6f59B++Ngg4cKexDJTvfOgeiv0sBc4sUm8CaBWH7OQC4kcCgrqbjJ/B2+6vO49exvTmYfdlPtcjbg==}