chore: format
This commit is contained in:
@@ -50,7 +50,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- 🔑 **Brotli dependency update.** Brotli has been updated to address CVE-2025-6176.
|
- 🔑 **Brotli dependency update.** Brotli has been updated to address CVE-2025-6176.
|
||||||
- 🖥️ **Windows startup script.** The Windows startup batch script has been updated for improved compatibility.
|
- 🖥️ **Windows startup script.** The Windows startup batch script has been updated for improved compatibility.
|
||||||
|
|
||||||
|
|
||||||
## [0.9.1] - 2026-04-21
|
## [0.9.1] - 2026-04-21
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -57,9 +57,7 @@ def _pop_first(params: dict[str, list[str]], key: str) -> str | None:
|
|||||||
|
|
||||||
def _is_postgres_url(url: str) -> bool:
|
def _is_postgres_url(url: str) -> bool:
|
||||||
"""Return True if *url* looks like a PostgreSQL connection string."""
|
"""Return True if *url* looks like a PostgreSQL connection string."""
|
||||||
return bool(url) and any(
|
return bool(url) and any(url.startswith(p) for p in ('postgresql://', 'postgresql+', 'postgres://'))
|
||||||
url.startswith(p) for p in ('postgresql://', 'postgresql+', 'postgres://')
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def extract_ssl_params_from_url(url: str) -> tuple[str, dict[str, str]]:
|
def extract_ssl_params_from_url(url: str) -> tuple[str, dict[str, str]]:
|
||||||
@@ -180,9 +178,7 @@ if ENABLE_DB_MIGRATIONS:
|
|||||||
_url_without_ssl, _ssl_dict = extract_ssl_params_from_url(DATABASE_URL)
|
_url_without_ssl, _ssl_dict = extract_ssl_params_from_url(DATABASE_URL)
|
||||||
|
|
||||||
# For psycopg2 (sync engine), re-append sslmode + cert-file params.
|
# For psycopg2 (sync engine), re-append sslmode + cert-file params.
|
||||||
SQLALCHEMY_DATABASE_URL = (
|
SQLALCHEMY_DATABASE_URL = reattach_ssl_params_to_url(_url_without_ssl, _ssl_dict) if _ssl_dict else DATABASE_URL
|
||||||
reattach_ssl_params_to_url(_url_without_ssl, _ssl_dict) if _ssl_dict else DATABASE_URL
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _make_async_url(url: str) -> str:
|
def _make_async_url(url: str) -> str:
|
||||||
|
|||||||
@@ -1869,6 +1869,7 @@ async def chat_completion(
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
log.info('Chat processing was cancelled')
|
log.info('Chat processing was cancelled')
|
||||||
try:
|
try:
|
||||||
|
|
||||||
async def emit_cancel_event():
|
async def emit_cancel_event():
|
||||||
event_emitter = await get_event_emitter(metadata)
|
event_emitter = await get_event_emitter(metadata)
|
||||||
if event_emitter:
|
if event_emitter:
|
||||||
@@ -1940,6 +1941,7 @@ async def chat_completion(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if metadata.get('chat_id'):
|
if metadata.get('chat_id'):
|
||||||
|
|
||||||
async def emit_inactive_event():
|
async def emit_inactive_event():
|
||||||
try:
|
try:
|
||||||
event_emitter = await get_event_emitter(metadata, update_db=False)
|
event_emitter = await get_event_emitter(metadata, update_db=False)
|
||||||
@@ -1947,7 +1949,7 @@ async def chat_completion(
|
|||||||
await event_emitter({'type': 'chat:active', 'data': {'active': False}})
|
await event_emitter({'type': 'chat:active', 'data': {'active': False}})
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Shield the event emission so it finishes even if the main task is cancelled
|
# Shield the event emission so it finishes even if the main task is cancelled
|
||||||
await asyncio.shield(emit_inactive_event())
|
await asyncio.shield(emit_inactive_event())
|
||||||
|
|||||||
@@ -391,15 +391,11 @@ class ModelsTable:
|
|||||||
|
|
||||||
return ModelListResponse(items=models, total=total)
|
return ModelListResponse(items=models, total=total)
|
||||||
|
|
||||||
async def get_model_meta_by_id(
|
async def get_model_meta_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[tuple[dict, int]]:
|
||||||
self, id: str, db: Optional[AsyncSession] = None
|
|
||||||
) -> Optional[tuple[dict, int]]:
|
|
||||||
"""Return (meta, updated_at) for a model, skipping access grant resolution."""
|
"""Return (meta, updated_at) for a model, skipping access grant resolution."""
|
||||||
try:
|
try:
|
||||||
async with get_async_db_context(db) as db:
|
async with get_async_db_context(db) as db:
|
||||||
result = await db.execute(
|
result = await db.execute(select(Model.meta, Model.updated_at).filter_by(id=id))
|
||||||
select(Model.meta, Model.updated_at).filter_by(id=id)
|
|
||||||
)
|
|
||||||
return result.first()
|
return result.first()
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -326,9 +326,7 @@ class OAuthSessionTable:
|
|||||||
"""Delete all OAuth sessions for a specific user and provider"""
|
"""Delete all OAuth sessions for a specific user and provider"""
|
||||||
try:
|
try:
|
||||||
async with get_async_db_context(db) as db:
|
async with get_async_db_context(db) as db:
|
||||||
result = await db.execute(
|
result = await db.execute(delete(OAuthSession).filter_by(user_id=user_id, provider=provider))
|
||||||
delete(OAuthSession).filter_by(user_id=user_id, provider=provider)
|
|
||||||
)
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return result.rowcount > 0
|
return result.rowcount > 0
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -400,10 +400,7 @@ class Loader:
|
|||||||
api_key=self.kwargs.get('MISTRAL_OCR_API_KEY'),
|
api_key=self.kwargs.get('MISTRAL_OCR_API_KEY'),
|
||||||
file_path=file_path,
|
file_path=file_path,
|
||||||
)
|
)
|
||||||
elif (
|
elif self.engine == 'paddleocr_vl' and self.kwargs.get('PADDLEOCR_VL_TOKEN') != '':
|
||||||
self.engine == 'paddleocr_vl'
|
|
||||||
and self.kwargs.get('PADDLEOCR_VL_TOKEN') != ''
|
|
||||||
):
|
|
||||||
loader = PaddleOCRVLLoader(
|
loader = PaddleOCRVLLoader(
|
||||||
api_url=self.kwargs.get('PADDLEOCR_VL_BASE_URL'),
|
api_url=self.kwargs.get('PADDLEOCR_VL_BASE_URL'),
|
||||||
token=self.kwargs.get('PADDLEOCR_VL_TOKEN'),
|
token=self.kwargs.get('PADDLEOCR_VL_TOKEN'),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from open_webui.env import GLOBAL_LOG_LEVEL
|
|||||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class PaddleOCRVLLoader:
|
class PaddleOCRVLLoader:
|
||||||
"""Loader that uses PaddleOCR-vl API to extract text from PDF/images."""
|
"""Loader that uses PaddleOCR-vl API to extract text from PDF/images."""
|
||||||
|
|
||||||
@@ -21,9 +22,9 @@ class PaddleOCRVLLoader:
|
|||||||
file_path: str,
|
file_path: str,
|
||||||
):
|
):
|
||||||
if not api_url or not token:
|
if not api_url or not token:
|
||||||
raise ValueError("PaddleOCR-vl API URL and Token are required.")
|
raise ValueError('PaddleOCR-vl API URL and Token are required.')
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
raise FileNotFoundError(f"File not found at {file_path}")
|
raise FileNotFoundError(f'File not found at {file_path}')
|
||||||
|
|
||||||
self.api_url = api_url.rstrip('/')
|
self.api_url = api_url.rstrip('/')
|
||||||
self.token = token
|
self.token = token
|
||||||
@@ -31,20 +32,17 @@ class PaddleOCRVLLoader:
|
|||||||
self.file_name = os.path.basename(file_path)
|
self.file_name = os.path.basename(file_path)
|
||||||
|
|
||||||
def load(self) -> List[Document]:
|
def load(self) -> List[Document]:
|
||||||
log.info(f"Processing with PaddleOCR-vl: {self.file_path}")
|
log.info(f'Processing with PaddleOCR-vl: {self.file_path}')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(self.file_path, "rb") as file:
|
with open(self.file_path, 'rb') as file:
|
||||||
file_bytes = file.read()
|
file_bytes = file.read()
|
||||||
file_data = base64.b64encode(file_bytes).decode("ascii")
|
file_data = base64.b64encode(file_bytes).decode('ascii')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"Failed to read file {self.file_path}: {e}")
|
log.error(f'Failed to read file {self.file_path}: {e}')
|
||||||
raise
|
raise
|
||||||
|
|
||||||
headers = {
|
headers = {'Authorization': f'token {self.token}', 'Content-Type': 'application/json'}
|
||||||
"Authorization": f"token {self.token}",
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Detect fileType based on file extension
|
# Detect fileType based on file extension
|
||||||
ext = self.file_path.lower().split('.')[-1]
|
ext = self.file_path.lower().split('.')[-1]
|
||||||
@@ -52,76 +50,76 @@ class PaddleOCRVLLoader:
|
|||||||
file_type = 1 if ext in image_extensions else 0
|
file_type = 1 if ext in image_extensions else 0
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"file": file_data,
|
'file': file_data,
|
||||||
"fileType": file_type,
|
'fileType': file_type,
|
||||||
"useDocOrientationClassify": False,
|
'useDocOrientationClassify': False,
|
||||||
"useDocUnwarping": False,
|
'useDocUnwarping': False,
|
||||||
"useChartRecognition": False,
|
'useChartRecognition': False,
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.post(f"{self.api_url}/layout-parsing", json=payload, headers=headers)
|
response = requests.post(f'{self.api_url}/layout-parsing', json=payload, headers=headers)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
result = response.json().get("result", {})
|
result = response.json().get('result', {})
|
||||||
layout_results = result.get("layoutParsingResults", [])
|
layout_results = result.get('layoutParsingResults', [])
|
||||||
|
|
||||||
documents = []
|
documents = []
|
||||||
total_pages = len(layout_results)
|
total_pages = len(layout_results)
|
||||||
skipped_pages = 0
|
skipped_pages = 0
|
||||||
|
|
||||||
for i, res in enumerate(layout_results):
|
for i, res in enumerate(layout_results):
|
||||||
markdown_text = res.get("markdown", {}).get("text", "")
|
markdown_text = res.get('markdown', {}).get('text', '')
|
||||||
|
|
||||||
if isinstance(markdown_text, str):
|
if isinstance(markdown_text, str):
|
||||||
cleaned_content = markdown_text.strip()
|
cleaned_content = markdown_text.strip()
|
||||||
else:
|
else:
|
||||||
cleaned_content = str(markdown_text).strip()
|
cleaned_content = str(markdown_text).strip()
|
||||||
|
|
||||||
if not cleaned_content:
|
if not cleaned_content:
|
||||||
skipped_pages += 1
|
skipped_pages += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
documents.append(
|
documents.append(
|
||||||
Document(
|
Document(
|
||||||
page_content=cleaned_content,
|
page_content=cleaned_content,
|
||||||
metadata={
|
metadata={
|
||||||
"page": i,
|
'page': i,
|
||||||
"page_label": i + 1,
|
'page_label': i + 1,
|
||||||
"total_pages": total_pages,
|
'total_pages': total_pages,
|
||||||
"file_name": self.file_name,
|
'file_name': self.file_name,
|
||||||
"processing_engine": "paddleocr-vl"
|
'processing_engine': 'paddleocr-vl',
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if skipped_pages > 0:
|
if skipped_pages > 0:
|
||||||
log.info(f"PaddleOCR-vl: Processed {len(documents)} pages, skipped {skipped_pages} empty pages.")
|
log.info(f'PaddleOCR-vl: Processed {len(documents)} pages, skipped {skipped_pages} empty pages.')
|
||||||
|
|
||||||
if not documents:
|
if not documents:
|
||||||
log.warning("No valid text content found by PaddleOCR-vl.")
|
log.warning('No valid text content found by PaddleOCR-vl.')
|
||||||
return [
|
return [
|
||||||
Document(
|
Document(
|
||||||
page_content="No valid text content found in document",
|
page_content='No valid text content found in document',
|
||||||
metadata={
|
metadata={
|
||||||
"error": "no_valid_pages",
|
'error': 'no_valid_pages',
|
||||||
"file_name": self.file_name,
|
'file_name': self.file_name,
|
||||||
"processing_engine": "paddleocr-vl"
|
'processing_engine': 'paddleocr-vl',
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
return documents
|
return documents
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"Error calling PaddleOCR-vl: {e}")
|
log.error(f'Error calling PaddleOCR-vl: {e}')
|
||||||
return [
|
return [
|
||||||
Document(
|
Document(
|
||||||
page_content=f"Error during OCR processing: {e}",
|
page_content=f'Error during OCR processing: {e}',
|
||||||
metadata={
|
metadata={
|
||||||
"error": "processing_failed",
|
'error': 'processing_failed',
|
||||||
"file_name": self.file_name,
|
'file_name': self.file_name,
|
||||||
"processing_engine": "paddleocr-vl"
|
'processing_engine': 'paddleocr-vl',
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -877,9 +877,7 @@ async def delete_oauth_session_by_provider(
|
|||||||
The provider string matches the 'provider' field in the oauth_session table
|
The provider string matches the 'provider' field in the oauth_session table
|
||||||
(e.g. 'mcp:server-id' for MCP connections).
|
(e.g. 'mcp:server-id' for MCP connections).
|
||||||
"""
|
"""
|
||||||
result = await OAuthSessions.delete_sessions_by_user_id_and_provider(
|
result = await OAuthSessions.delete_sessions_by_user_id_and_provider(user.id, provider, db=db)
|
||||||
user.id, provider, db=db
|
|
||||||
)
|
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
|||||||
@@ -917,4 +917,3 @@ async def update_tools_user_valves_by_id(
|
|||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -335,7 +335,9 @@ async def get_tool_module_from_cache(request, tool_id, load_from_db=True):
|
|||||||
return tool_module, frontmatter
|
return tool_module, frontmatter
|
||||||
|
|
||||||
|
|
||||||
async def get_function_module_from_cache(request, function_id, function: FunctionModel | None = None, load_from_db=True):
|
async def get_function_module_from_cache(
|
||||||
|
request, function_id, function: FunctionModel | None = None, load_from_db=True
|
||||||
|
):
|
||||||
if load_from_db:
|
if load_from_db:
|
||||||
# Always load from the database by default
|
# Always load from the database by default
|
||||||
# This is useful for hooks like "inlet" or "outlet" where the content might change
|
# This is useful for hooks like "inlet" or "outlet" where the content might change
|
||||||
|
|||||||
@@ -647,4 +647,3 @@ export const setBanners = async (token: string, banners: Banner[]) => {
|
|||||||
|
|
||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -483,4 +483,3 @@ export const updateUserValvesById = async (token: string, id: string, valves: ob
|
|||||||
|
|
||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -550,4 +550,3 @@ export const getUserGroupsById = async (token: string, userId: string) => {
|
|||||||
|
|
||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1369,12 +1369,13 @@
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if RAGConfig.RAG_TEMPLATE && ((RAGConfig.RAG_TEMPLATE.match(/\[context\]/g) || []).length + (RAGConfig.RAG_TEMPLATE.match(/\{\{CONTEXT\}\}/g) || []).length) > 1}
|
{#if RAGConfig.RAG_TEMPLATE && (RAGConfig.RAG_TEMPLATE.match(/\[context\]/g) || []).length + (RAGConfig.RAG_TEMPLATE.match(/\{\{CONTEXT\}\}/g) || []).length > 1}
|
||||||
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||||
{$i18n.t('This template contains multiple context placeholders ([context] or {{CONTEXT}}). Context will be injected at each occurrence.')}
|
{$i18n.t(
|
||||||
|
'This template contains multiple context placeholders ([context] or {{CONTEXT}}). Context will be injected at each occurrence.'
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -406,7 +406,7 @@
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LinkSlash className="size-3.5" />
|
<LinkSlash className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -138,4 +138,3 @@
|
|||||||
contain-intrinsic-size: auto 150px;
|
contain-intrinsic-size: auto 150px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -517,7 +517,7 @@
|
|||||||
"Delete a model": "모델 삭제",
|
"Delete a model": "모델 삭제",
|
||||||
"Delete All": "모두 삭제",
|
"Delete All": "모두 삭제",
|
||||||
"Delete All Chats": "모든 채팅 삭제",
|
"Delete All Chats": "모든 채팅 삭제",
|
||||||
"Delete all contents inside this folder":"이 폴더 내 모든 콘텐츠 삭제",
|
"Delete all contents inside this folder": "이 폴더 내 모든 콘텐츠 삭제",
|
||||||
"Delete automation?": "자동 삭제하시겠습니까?",
|
"Delete automation?": "자동 삭제하시겠습니까?",
|
||||||
"Delete Chat": "채팅 삭제",
|
"Delete Chat": "채팅 삭제",
|
||||||
"Delete chat?": "채팅을 삭제하시겠습니까?",
|
"Delete chat?": "채팅을 삭제하시겠습니까?",
|
||||||
@@ -1350,7 +1350,7 @@
|
|||||||
"new-channel": "새 채널",
|
"new-channel": "새 채널",
|
||||||
"Next message": "다음 메시지",
|
"Next message": "다음 메시지",
|
||||||
"Next run": "다음 실행",
|
"Next run": "다음 실행",
|
||||||
"No access grants. Private to you.": "접근 권한이 없습니다. 개인용입니다.",
|
"No access grants. Private to you.": "접근 권한이 없습니다. 개인용입니다.",
|
||||||
"No activity data": "활동 데이터가 없습니다",
|
"No activity data": "활동 데이터가 없습니다",
|
||||||
"No authentication": "권한 인증이 없습니다",
|
"No authentication": "권한 인증이 없습니다",
|
||||||
"No automations found": "자동화된 항목을 찾을 수 없습니다.",
|
"No automations found": "자동화된 항목을 찾을 수 없습니다.",
|
||||||
@@ -1697,8 +1697,8 @@
|
|||||||
"Search": "검색",
|
"Search": "검색",
|
||||||
"Search a model": "모델 검색",
|
"Search a model": "모델 검색",
|
||||||
"Search all emojis": "모든 이모지 검색",
|
"Search all emojis": "모든 이모지 검색",
|
||||||
"Search and manage user memories":"사용자 기억 검색 및 관리",
|
"Search and manage user memories": "사용자 기억 검색 및 관리",
|
||||||
"Search and view user chat history":"사용자 채팅 기록 검색 및 보기",
|
"Search and view user chat history": "사용자 채팅 기록 검색 및 보기",
|
||||||
"Search Automations": "자동 검색",
|
"Search Automations": "자동 검색",
|
||||||
"Search Base": "검색 기반",
|
"Search Base": "검색 기반",
|
||||||
"Search channels and channel messages": "채널 및 채널 메시지 검색",
|
"Search channels and channel messages": "채널 및 채널 메시지 검색",
|
||||||
@@ -2003,7 +2003,7 @@
|
|||||||
"Tika Server URL required.": "Tika 서버 URL이 필요합니다.",
|
"Tika Server URL required.": "Tika 서버 URL이 필요합니다.",
|
||||||
"Tiktoken": "틱토큰 (Tiktoken)",
|
"Tiktoken": "틱토큰 (Tiktoken)",
|
||||||
"Time": "시간",
|
"Time": "시간",
|
||||||
"Time & Calculation":"시간 및 계산",
|
"Time & Calculation": "시간 및 계산",
|
||||||
"Timeout": "시간 초과",
|
"Timeout": "시간 초과",
|
||||||
"Title": "제목",
|
"Title": "제목",
|
||||||
"Title Auto-Generation": "제목 자동 생성",
|
"Title Auto-Generation": "제목 자동 생성",
|
||||||
|
|||||||
@@ -489,7 +489,10 @@
|
|||||||
const displayTitle = title || $i18n.t('New Chat');
|
const displayTitle = title || $i18n.t('New Chat');
|
||||||
|
|
||||||
if (done) {
|
if (done) {
|
||||||
if (($settings?.notificationSound ?? true) && ($settings?.notificationSoundAlways ?? false)) {
|
if (
|
||||||
|
($settings?.notificationSound ?? true) &&
|
||||||
|
($settings?.notificationSoundAlways ?? false)
|
||||||
|
) {
|
||||||
playingNotificationSound.set(true);
|
playingNotificationSound.set(true);
|
||||||
|
|
||||||
const audio = new Audio(`/audio/notification.mp3`);
|
const audio = new Audio(`/audio/notification.mp3`);
|
||||||
|
|||||||
Reference in New Issue
Block a user