Merge remote-tracking branch 'upstream/dev' into fix/connector
|
|
@ -4,13 +4,14 @@ from .change_tracker import categorize_change, fetch_all_changes, get_start_page
|
|||
from .client import GoogleDriveClient
|
||||
from .content_extractor import download_and_process_file
|
||||
from .credentials import get_valid_credentials, validate_credentials
|
||||
from .folder_manager import get_files_in_folder, list_folder_contents
|
||||
from .folder_manager import get_file_by_id, get_files_in_folder, list_folder_contents
|
||||
|
||||
__all__ = [
|
||||
"GoogleDriveClient",
|
||||
"categorize_change",
|
||||
"download_and_process_file",
|
||||
"fetch_all_changes",
|
||||
"get_file_by_id",
|
||||
"get_files_in_folder",
|
||||
"get_start_page_token",
|
||||
"get_valid_credentials",
|
||||
|
|
|
|||
|
|
@ -140,6 +140,39 @@ async def get_files_in_folder(
|
|||
return [], None, f"Error getting files in folder: {e!s}"
|
||||
|
||||
|
||||
async def get_file_by_id(
|
||||
client: GoogleDriveClient,
|
||||
file_id: str,
|
||||
) -> tuple[dict[str, Any] | None, str | None]:
|
||||
"""
|
||||
Get file metadata by ID.
|
||||
|
||||
Args:
|
||||
client: GoogleDriveClient instance
|
||||
file_id: File ID to fetch
|
||||
|
||||
Returns:
|
||||
Tuple of (file metadata dict, error message)
|
||||
"""
|
||||
try:
|
||||
file, error = await client.get_file_metadata(
|
||||
file_id,
|
||||
fields="id, name, mimeType, parents, createdTime, modifiedTime, size, webViewLink, iconLink",
|
||||
)
|
||||
|
||||
if error:
|
||||
return None, error
|
||||
|
||||
if not file:
|
||||
return None, f"File not found: {file_id}"
|
||||
|
||||
return file, None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting file by ID: {e!s}", exc_info=True)
|
||||
return None, f"Error getting file by ID: {e!s}"
|
||||
|
||||
|
||||
def format_folder_path(hierarchy: list[dict[str, str]]) -> str:
|
||||
"""
|
||||
Format folder hierarchy as a path string.
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import logging
|
|||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -30,6 +30,7 @@ from app.db import (
|
|||
get_async_session,
|
||||
)
|
||||
from app.schemas import (
|
||||
GoogleDriveIndexRequest,
|
||||
SearchSourceConnectorBase,
|
||||
SearchSourceConnectorCreate,
|
||||
SearchSourceConnectorRead,
|
||||
|
|
@ -542,13 +543,9 @@ async def index_connector_content(
|
|||
None,
|
||||
description="End date for indexing (YYYY-MM-DD format). If not provided, uses today's date",
|
||||
),
|
||||
folder_ids: str = Query(
|
||||
drive_items: GoogleDriveIndexRequest | None = Body(
|
||||
None,
|
||||
description="[Google Drive only] Comma-separated folder IDs to index",
|
||||
),
|
||||
folder_names: str = Query(
|
||||
None,
|
||||
description="[Google Drive only] Comma-separated folder names for display purposes",
|
||||
description="[Google Drive only] Structured request with folders and files to index",
|
||||
),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
|
|
@ -762,22 +759,23 @@ async def index_connector_content(
|
|||
index_google_drive_files_task,
|
||||
)
|
||||
|
||||
if not folder_ids or not folder_names:
|
||||
if not drive_items or not drive_items.has_items():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Google Drive indexing requires folder_ids and folder_names parameters",
|
||||
detail="Google Drive indexing requires drive_items body parameter with folders or files",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Triggering Google Drive indexing for connector {connector_id} into search space {search_space_id}, folders: {folder_names}"
|
||||
f"Triggering Google Drive indexing for connector {connector_id} into search space {search_space_id}, "
|
||||
f"folders: {len(drive_items.folders)}, files: {len(drive_items.files)}"
|
||||
)
|
||||
# Pass comma-separated strings directly to Celery task
|
||||
|
||||
# Pass structured data to Celery task
|
||||
index_google_drive_files_task.delay(
|
||||
connector_id,
|
||||
search_space_id,
|
||||
str(user.id),
|
||||
folder_ids, # Pass as comma-separated string
|
||||
folder_names, # Pass as comma-separated string
|
||||
drive_items.model_dump(), # Convert to dict for JSON serialization
|
||||
)
|
||||
response_message = "Google Drive indexing started in the background."
|
||||
|
||||
|
|
@ -1554,45 +1552,63 @@ async def run_google_drive_indexing(
|
|||
connector_id: int,
|
||||
search_space_id: int,
|
||||
user_id: str,
|
||||
folder_ids: str, # Comma-separated folder IDs
|
||||
folder_names: str, # Comma-separated folder names
|
||||
items_dict: dict, # Dictionary with 'folders' and 'files' lists
|
||||
):
|
||||
"""Runs the Google Drive indexing task for multiple folders and updates the timestamp."""
|
||||
"""Runs the Google Drive indexing task for folders and files and updates the timestamp."""
|
||||
try:
|
||||
from app.tasks.connector_indexers.google_drive_indexer import (
|
||||
index_google_drive_files,
|
||||
index_google_drive_single_file,
|
||||
)
|
||||
|
||||
# Split comma-separated IDs and names into lists
|
||||
folder_id_list = [fid.strip() for fid in folder_ids.split(",")]
|
||||
folder_name_list = [fname.strip() for fname in folder_names.split(",")]
|
||||
|
||||
# Parse the structured data
|
||||
items = GoogleDriveIndexRequest(**items_dict)
|
||||
total_indexed = 0
|
||||
errors = []
|
||||
|
||||
# Index each folder
|
||||
for folder_id, folder_name in zip(
|
||||
folder_id_list, folder_name_list, strict=False
|
||||
):
|
||||
for folder in items.folders:
|
||||
try:
|
||||
indexed_count, error_message = await index_google_drive_files(
|
||||
session,
|
||||
connector_id,
|
||||
search_space_id,
|
||||
user_id,
|
||||
folder_id,
|
||||
folder_name,
|
||||
folder_id=folder.id,
|
||||
folder_name=folder.name,
|
||||
use_delta_sync=True,
|
||||
update_last_indexed=False,
|
||||
)
|
||||
if error_message:
|
||||
errors.append(f"{folder_name}: {error_message}")
|
||||
errors.append(f"Folder '{folder.name}': {error_message}")
|
||||
else:
|
||||
total_indexed += indexed_count
|
||||
except Exception as e:
|
||||
errors.append(f"{folder_name}: {e!s}")
|
||||
errors.append(f"Folder '{folder.name}': {e!s}")
|
||||
logger.error(
|
||||
f"Error indexing folder {folder_name} ({folder_id}): {e}",
|
||||
f"Error indexing folder {folder.name} ({folder.id}): {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Index each individual file
|
||||
for file in items.files:
|
||||
try:
|
||||
indexed_count, error_message = await index_google_drive_single_file(
|
||||
session,
|
||||
connector_id,
|
||||
search_space_id,
|
||||
user_id,
|
||||
file_id=file.id,
|
||||
file_name=file.name,
|
||||
)
|
||||
if error_message:
|
||||
errors.append(f"File '{file.name}': {error_message}")
|
||||
else:
|
||||
total_indexed += indexed_count
|
||||
except Exception as e:
|
||||
errors.append(f"File '{file.name}': {e!s}")
|
||||
logger.error(
|
||||
f"Error indexing file {file.name} ({file.id}): {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
|
@ -1602,7 +1618,7 @@ async def run_google_drive_indexing(
|
|||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Google Drive indexing successful for connector {connector_id}. Indexed {total_indexed} documents from {len(folder_id_list)} folder(s)."
|
||||
f"Google Drive indexing successful for connector {connector_id}. Indexed {total_indexed} documents from {len(items.folders)} folder(s) and {len(items.files)} file(s)."
|
||||
)
|
||||
# Update the last indexed timestamp only on full success
|
||||
await update_connector_last_indexed(session, connector_id)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from .documents import (
|
|||
ExtensionDocumentMetadata,
|
||||
PaginatedResponse,
|
||||
)
|
||||
from .google_drive import DriveItem, GoogleDriveIndexRequest
|
||||
from .logs import LogBase, LogCreate, LogFilter, LogRead, LogUpdate
|
||||
from .new_chat import (
|
||||
ChatMessage,
|
||||
|
|
@ -79,6 +80,8 @@ __all__ = [
|
|||
"DefaultSystemInstructionsResponse",
|
||||
# Document schemas
|
||||
"DocumentBase",
|
||||
# Google Drive schemas
|
||||
"DriveItem",
|
||||
"DocumentRead",
|
||||
"DocumentUpdate",
|
||||
"DocumentWithChunksRead",
|
||||
|
|
@ -86,6 +89,7 @@ __all__ = [
|
|||
"ExtensionDocumentContent",
|
||||
"ExtensionDocumentMetadata",
|
||||
"GlobalNewLLMConfigRead",
|
||||
"GoogleDriveIndexRequest",
|
||||
# Base schemas
|
||||
"IDModel",
|
||||
# RBAC schemas
|
||||
|
|
|
|||
42
surfsense_backend/app/schemas/google_drive.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"""Schemas for Google Drive connector."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class DriveItem(BaseModel):
|
||||
"""Represents a Google Drive file or folder."""
|
||||
|
||||
id: str = Field(..., description="Google Drive item ID")
|
||||
name: str = Field(..., description="Item display name")
|
||||
|
||||
|
||||
class GoogleDriveIndexRequest(BaseModel):
|
||||
"""Request body for indexing Google Drive content."""
|
||||
|
||||
folders: list[DriveItem] = Field(
|
||||
default_factory=list, description="List of folders to index"
|
||||
)
|
||||
files: list[DriveItem] = Field(
|
||||
default_factory=list, description="List of specific files to index"
|
||||
)
|
||||
|
||||
def has_items(self) -> bool:
|
||||
"""Check if any items are selected."""
|
||||
return len(self.folders) > 0 or len(self.files) > 0
|
||||
|
||||
def get_folder_ids(self) -> list[str]:
|
||||
"""Get list of folder IDs."""
|
||||
return [folder.id for folder in self.folders]
|
||||
|
||||
def get_folder_names(self) -> list[str]:
|
||||
"""Get list of folder names."""
|
||||
return [folder.name for folder in self.folders]
|
||||
|
||||
def get_file_ids(self) -> list[str]:
|
||||
"""Get list of file IDs."""
|
||||
return [file.id for file in self.files]
|
||||
|
||||
def get_file_names(self) -> list[str]:
|
||||
"""Get list of file names."""
|
||||
return [file.name for file in self.files]
|
||||
|
||||
|
|
@ -479,10 +479,9 @@ def index_google_drive_files_task(
|
|||
connector_id: int,
|
||||
search_space_id: int,
|
||||
user_id: str,
|
||||
folder_ids: str, # Comma-separated folder IDs
|
||||
folder_names: str, # Comma-separated folder names
|
||||
items_dict: dict, # Dictionary with 'folders' and 'files' lists
|
||||
):
|
||||
"""Celery task to index Google Drive files from multiple folders."""
|
||||
"""Celery task to index Google Drive folders and files."""
|
||||
import asyncio
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
|
|
@ -494,8 +493,7 @@ def index_google_drive_files_task(
|
|||
connector_id,
|
||||
search_space_id,
|
||||
user_id,
|
||||
folder_ids,
|
||||
folder_names,
|
||||
items_dict,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
|
|
@ -506,10 +504,9 @@ async def _index_google_drive_files(
|
|||
connector_id: int,
|
||||
search_space_id: int,
|
||||
user_id: str,
|
||||
folder_ids: str, # Comma-separated folder IDs
|
||||
folder_names: str, # Comma-separated folder names
|
||||
items_dict: dict, # Dictionary with 'folders' and 'files' lists
|
||||
):
|
||||
"""Index Google Drive files from multiple folders with new session."""
|
||||
"""Index Google Drive folders and files with new session."""
|
||||
from app.routes.search_source_connectors_routes import (
|
||||
run_google_drive_indexing,
|
||||
)
|
||||
|
|
@ -520,8 +517,7 @@ async def _index_google_drive_files(
|
|||
connector_id,
|
||||
search_space_id,
|
||||
user_id,
|
||||
folder_ids,
|
||||
folder_names,
|
||||
items_dict,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from app.connectors.google_drive import (
|
|||
categorize_change,
|
||||
download_and_process_file,
|
||||
fetch_all_changes,
|
||||
get_file_by_id,
|
||||
get_files_in_folder,
|
||||
get_start_page_token,
|
||||
)
|
||||
|
|
@ -194,6 +195,131 @@ async def index_google_drive_files(
|
|||
return 0, f"Failed to index Google Drive files: {e!s}"
|
||||
|
||||
|
||||
async def index_google_drive_single_file(
|
||||
session: AsyncSession,
|
||||
connector_id: int,
|
||||
search_space_id: int,
|
||||
user_id: str,
|
||||
file_id: str,
|
||||
file_name: str | None = None,
|
||||
) -> tuple[int, str | None]:
|
||||
"""
|
||||
Index a single Google Drive file by its ID.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
connector_id: ID of the Drive connector
|
||||
search_space_id: ID of the search space
|
||||
user_id: ID of the user
|
||||
file_id: Specific file ID to index
|
||||
file_name: File name for display (optional)
|
||||
|
||||
Returns:
|
||||
Tuple of (number_of_indexed_files, error_message)
|
||||
"""
|
||||
task_logger = TaskLoggingService(session, search_space_id)
|
||||
|
||||
log_entry = await task_logger.log_task_start(
|
||||
task_name="google_drive_single_file_indexing",
|
||||
source="connector_indexing_task",
|
||||
message=f"Starting Google Drive single file indexing for file {file_id}",
|
||||
metadata={
|
||||
"connector_id": connector_id,
|
||||
"user_id": str(user_id),
|
||||
"file_id": file_id,
|
||||
"file_name": file_name,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
connector = await get_connector_by_id(
|
||||
session, connector_id, SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR
|
||||
)
|
||||
|
||||
if not connector:
|
||||
error_msg = f"Google Drive connector with ID {connector_id} not found"
|
||||
await task_logger.log_task_failure(
|
||||
log_entry, error_msg, {"error_type": "ConnectorNotFound"}
|
||||
)
|
||||
return 0, error_msg
|
||||
|
||||
await task_logger.log_task_progress(
|
||||
log_entry,
|
||||
f"Initializing Google Drive client for connector {connector_id}",
|
||||
{"stage": "client_initialization"},
|
||||
)
|
||||
|
||||
drive_client = GoogleDriveClient(session, connector_id)
|
||||
|
||||
# Fetch the file metadata
|
||||
file, error = await get_file_by_id(drive_client, file_id)
|
||||
|
||||
if error or not file:
|
||||
error_msg = f"Failed to fetch file {file_id}: {error or 'File not found'}"
|
||||
await task_logger.log_task_failure(
|
||||
log_entry, error_msg, {"error_type": "FileNotFound"}
|
||||
)
|
||||
return 0, error_msg
|
||||
|
||||
display_name = file_name or file.get("name", "Unknown")
|
||||
logger.info(f"Indexing Google Drive file: {display_name} ({file_id})")
|
||||
|
||||
# Process the file
|
||||
indexed, skipped = await _process_single_file(
|
||||
drive_client=drive_client,
|
||||
session=session,
|
||||
file=file,
|
||||
connector_id=connector_id,
|
||||
search_space_id=search_space_id,
|
||||
user_id=user_id,
|
||||
task_logger=task_logger,
|
||||
log_entry=log_entry,
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
logger.info("Successfully committed Google Drive file indexing changes to database")
|
||||
|
||||
if indexed > 0:
|
||||
await task_logger.log_task_success(
|
||||
log_entry,
|
||||
f"Successfully indexed file {display_name}",
|
||||
{
|
||||
"file_name": display_name,
|
||||
"file_id": file_id,
|
||||
},
|
||||
)
|
||||
logger.info(f"Google Drive file indexing completed: {display_name}")
|
||||
return 1, None
|
||||
else:
|
||||
await task_logger.log_task_progress(
|
||||
log_entry,
|
||||
f"File {display_name} was skipped",
|
||||
{"status": "skipped"},
|
||||
)
|
||||
return 0, None
|
||||
|
||||
except SQLAlchemyError as db_error:
|
||||
await session.rollback()
|
||||
await task_logger.log_task_failure(
|
||||
log_entry,
|
||||
f"Database error during file indexing",
|
||||
str(db_error),
|
||||
{"error_type": "SQLAlchemyError"},
|
||||
)
|
||||
logger.error(f"Database error: {db_error!s}", exc_info=True)
|
||||
return 0, f"Database error: {db_error!s}"
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
await task_logger.log_task_failure(
|
||||
log_entry,
|
||||
f"Failed to index Google Drive file",
|
||||
str(e),
|
||||
{"error_type": type(e).__name__},
|
||||
)
|
||||
logger.error(f"Failed to index Google Drive file: {e!s}", exc_info=True)
|
||||
return 0, f"Failed to index Google Drive file: {e!s}"
|
||||
|
||||
|
||||
async def _index_full_scan(
|
||||
drive_client: GoogleDriveClient,
|
||||
session: AsyncSession,
|
||||
|
|
|
|||
|
|
@ -118,9 +118,10 @@ export default function ConnectorsPage() {
|
|||
const [customFrequency, setCustomFrequency] = useState<string>("");
|
||||
const [isSavingPeriodic, setIsSavingPeriodic] = useState(false);
|
||||
|
||||
// Google Drive folder selection state
|
||||
// Google Drive folder and file selection state
|
||||
const [driveFolderDialogOpen, setDriveFolderDialogOpen] = useState(false);
|
||||
const [selectedFolders, setSelectedFolders] = useState<Array<{ id: string; name: string }>>([]);
|
||||
const [selectedFiles, setSelectedFiles] = useState<Array<{ id: string; name: string }>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
|
|
@ -161,10 +162,10 @@ export default function ConnectorsPage() {
|
|||
setDriveFolderDialogOpen(true);
|
||||
};
|
||||
|
||||
// Handle Google Drive folder indexing
|
||||
const handleIndexDriveFolder = async () => {
|
||||
if (selectedConnectorForIndexing === null || selectedFolders.length === 0) {
|
||||
toast.error("Please select at least one folder");
|
||||
// Handle Google Drive folder and file indexing
|
||||
const handleIndexGoogleDrive = async () => {
|
||||
if (selectedConnectorForIndexing === null || (selectedFolders.length === 0 && selectedFiles.length === 0)) {
|
||||
toast.error("Please select at least one folder or file");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -173,15 +174,14 @@ export default function ConnectorsPage() {
|
|||
try {
|
||||
setIndexingConnectorId(selectedConnectorForIndexing);
|
||||
|
||||
const folderIds = selectedFolders.map((f) => f.id).join(",");
|
||||
const folderNames = selectedFolders.map((f) => f.name).join(", ");
|
||||
|
||||
await indexConnector({
|
||||
connector_id: selectedConnectorForIndexing,
|
||||
body: {
|
||||
folders: selectedFolders,
|
||||
files: selectedFiles,
|
||||
},
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
folder_ids: folderIds,
|
||||
folder_names: folderNames,
|
||||
},
|
||||
});
|
||||
toast.success(t("indexing_started"));
|
||||
|
|
@ -189,10 +189,11 @@ export default function ConnectorsPage() {
|
|||
console.error("Error indexing connector content:", error);
|
||||
toast.error(error instanceof Error ? error.message : t("indexing_failed"));
|
||||
} finally {
|
||||
setIndexingConnectorId(null);
|
||||
setSelectedConnectorForIndexing(null);
|
||||
setSelectedFolders([]);
|
||||
}
|
||||
setIndexingConnectorId(null);
|
||||
setSelectedConnectorForIndexing(null);
|
||||
setSelectedFolders([]);
|
||||
setSelectedFiles([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle connector indexing with dates
|
||||
|
|
@ -670,11 +671,11 @@ export default function ConnectorsPage() {
|
|||
<Dialog open={driveFolderDialogOpen} onOpenChange={setDriveFolderDialogOpen}>
|
||||
<DialogContent className="w-auto max-w-full">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Select Google Drive Folders</DialogTitle>
|
||||
<DialogTitle>Select Google Drive Folders & Files</DialogTitle>
|
||||
<DialogDescription className="flex items-start gap-2 text-sm p-2 border mt-1 rounded ">
|
||||
<Info className="h-4 w-4 shrink-0 text-blue-500" />
|
||||
<span>
|
||||
Select folders to index. Only files <strong>directly in each folder</strong> will be
|
||||
Select folders and/or individual files to index. For folders, only files <strong>directly in each folder</strong> will be
|
||||
processed—subfolders must be selected separately.
|
||||
</span>
|
||||
</DialogDescription>
|
||||
|
|
@ -689,23 +690,43 @@ export default function ConnectorsPage() {
|
|||
onSelectFolders={(folders) => {
|
||||
setSelectedFolders(folders);
|
||||
}}
|
||||
selectedFiles={selectedFiles}
|
||||
onSelectFiles={(files) => {
|
||||
setSelectedFiles(files);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{selectedFolders.length > 0 && (
|
||||
{(selectedFolders.length > 0 || selectedFiles.length > 0) && (
|
||||
<div className="p-3 bg-muted rounded-lg text-sm space-y-2">
|
||||
<div>
|
||||
<p className="font-medium mb-1">
|
||||
Selected {selectedFolders.length} folder{selectedFolders.length > 1 ? "s" : ""}:
|
||||
</p>
|
||||
<div className="max-h-24 overflow-y-auto">
|
||||
{selectedFolders.map((folder) => (
|
||||
<p key={folder.id} className="text-sm text-muted-foreground truncate" title={folder.name}>
|
||||
• {folder.name}
|
||||
</p>
|
||||
))}
|
||||
{selectedFolders.length > 0 && (
|
||||
<div>
|
||||
<p className="font-medium mb-1">
|
||||
Selected {selectedFolders.length} folder{selectedFolders.length > 1 ? "s" : ""}:
|
||||
</p>
|
||||
<div className="max-h-24 overflow-y-auto">
|
||||
{selectedFolders.map((folder) => (
|
||||
<p key={folder.id} className="text-sm text-muted-foreground truncate" title={folder.name}>
|
||||
📁 {folder.name}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedFiles.length > 0 && (
|
||||
<div>
|
||||
<p className="font-medium mb-1">
|
||||
Selected {selectedFiles.length} file{selectedFiles.length > 1 ? "s" : ""}:
|
||||
</p>
|
||||
<div className="max-h-24 overflow-y-auto">
|
||||
{selectedFiles.map((file) => (
|
||||
<p key={file.id} className="text-sm text-muted-foreground truncate" title={file.name}>
|
||||
📄 {file.name}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -716,11 +737,12 @@ export default function ConnectorsPage() {
|
|||
setDriveFolderDialogOpen(false);
|
||||
setSelectedConnectorForIndexing(null);
|
||||
setSelectedFolders([]);
|
||||
setSelectedFiles([]);
|
||||
}}
|
||||
>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleIndexDriveFolder} disabled={selectedFolders.length === 0}>
|
||||
<Button onClick={handleIndexGoogleDrive} disabled={selectedFolders.length === 0 && selectedFiles.length === 0}>
|
||||
{t("start_indexing")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
|
|
|||
118
surfsense_web/components/assistant-ui/assistant-message.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import {
|
||||
ActionBarPrimitive,
|
||||
AssistantIf,
|
||||
ErrorPrimitive,
|
||||
MessagePrimitive,
|
||||
useAssistantState,
|
||||
} from "@assistant-ui/react";
|
||||
import { CheckIcon, CopyIcon, DownloadIcon, RefreshCwIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useContext } from "react";
|
||||
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
||||
import { ThinkingStepsContext, ThinkingStepsDisplay } from "@/components/assistant-ui/thinking-steps";
|
||||
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { BranchPicker } from "@/components/assistant-ui/branch-picker";
|
||||
|
||||
export const MessageError: FC = () => {
|
||||
return (
|
||||
<MessagePrimitive.Error>
|
||||
<ErrorPrimitive.Root className="aui-message-error-root mt-2 rounded-md border border-destructive bg-destructive/10 p-3 text-destructive text-sm dark:bg-destructive/5 dark:text-red-200">
|
||||
<ErrorPrimitive.Message className="aui-message-error-message line-clamp-2" />
|
||||
</ErrorPrimitive.Root>
|
||||
</MessagePrimitive.Error>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom component to render thinking steps from Context
|
||||
*/
|
||||
const ThinkingStepsPart: FC = () => {
|
||||
const thinkingStepsMap = useContext(ThinkingStepsContext);
|
||||
|
||||
// Get the current message ID to look up thinking steps
|
||||
const messageId = useAssistantState(({ message }) => message?.id);
|
||||
const thinkingSteps = thinkingStepsMap.get(messageId) || [];
|
||||
|
||||
// Check if this specific message is currently streaming
|
||||
// A message is streaming if: thread is running AND this is the last assistant message
|
||||
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
|
||||
const isLastMessage = useAssistantState(({ message }) => message?.isLast ?? false);
|
||||
const isMessageStreaming = isThreadRunning && isLastMessage;
|
||||
|
||||
if (thinkingSteps.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<ThinkingStepsDisplay steps={thinkingSteps} isThreadRunning={isMessageStreaming} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AssistantMessageInner: FC = () => {
|
||||
return (
|
||||
<>
|
||||
{/* Render thinking steps from message content - this ensures proper scroll tracking */}
|
||||
<ThinkingStepsPart />
|
||||
|
||||
<div className="aui-assistant-message-content wrap-break-word px-2 text-foreground leading-relaxed">
|
||||
<MessagePrimitive.Parts
|
||||
components={{
|
||||
Text: MarkdownText,
|
||||
tools: { Fallback: ToolFallback },
|
||||
}}
|
||||
/>
|
||||
<MessageError />
|
||||
</div>
|
||||
|
||||
<div className="aui-assistant-message-footer mt-1 mb-5 ml-2 flex">
|
||||
<BranchPicker />
|
||||
<AssistantActionBar />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const AssistantMessage: FC = () => {
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="aui-assistant-message-root fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
|
||||
data-role="assistant"
|
||||
>
|
||||
<AssistantMessageInner />
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const AssistantActionBar: FC = () => {
|
||||
return (
|
||||
<ActionBarPrimitive.Root
|
||||
hideWhenRunning
|
||||
autohide="not-last"
|
||||
autohideFloat="single-branch"
|
||||
className="aui-assistant-action-bar-root -ml-1 col-start-3 row-start-2 flex gap-1 text-muted-foreground data-floating:absolute data-floating:rounded-md data-floating:border data-floating:bg-background data-floating:p-1 data-floating:shadow-sm"
|
||||
>
|
||||
<ActionBarPrimitive.Copy asChild>
|
||||
<TooltipIconButton tooltip="Copy">
|
||||
<AssistantIf condition={({ message }) => message.isCopied}>
|
||||
<CheckIcon />
|
||||
</AssistantIf>
|
||||
<AssistantIf condition={({ message }) => !message.isCopied}>
|
||||
<CopyIcon />
|
||||
</AssistantIf>
|
||||
</TooltipIconButton>
|
||||
</ActionBarPrimitive.Copy>
|
||||
<ActionBarPrimitive.ExportMarkdown asChild>
|
||||
<TooltipIconButton tooltip="Export as Markdown">
|
||||
<DownloadIcon />
|
||||
</TooltipIconButton>
|
||||
</ActionBarPrimitive.ExportMarkdown>
|
||||
<ActionBarPrimitive.Reload asChild>
|
||||
<TooltipIconButton tooltip="Refresh">
|
||||
<RefreshCwIcon />
|
||||
</TooltipIconButton>
|
||||
</ActionBarPrimitive.Reload>
|
||||
</ActionBarPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
33
surfsense_web/components/assistant-ui/branch-picker.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { BranchPickerPrimitive } from "@assistant-ui/react";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const BranchPicker: FC<BranchPickerPrimitive.Root.Props> = ({ className, ...rest }) => {
|
||||
return (
|
||||
<BranchPickerPrimitive.Root
|
||||
hideWhenSingleBranch
|
||||
className={cn(
|
||||
"aui-branch-picker-root -ml-2 mr-2 inline-flex items-center text-muted-foreground text-xs",
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<BranchPickerPrimitive.Previous asChild>
|
||||
<TooltipIconButton tooltip="Previous">
|
||||
<ChevronLeftIcon />
|
||||
</TooltipIconButton>
|
||||
</BranchPickerPrimitive.Previous>
|
||||
<span className="aui-branch-picker-state font-medium">
|
||||
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
|
||||
</span>
|
||||
<BranchPickerPrimitive.Next asChild>
|
||||
<TooltipIconButton tooltip="Next">
|
||||
<ChevronRightIcon />
|
||||
</TooltipIconButton>
|
||||
</BranchPickerPrimitive.Next>
|
||||
</BranchPickerPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
271
surfsense_web/components/assistant-ui/composer-action.tsx
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import { AssistantIf, ComposerPrimitive, useAssistantState } from "@assistant-ui/react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertCircle, ArrowUpIcon, Loader2, Plus, Plug2, SquareIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { FC } from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
|
||||
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
|
||||
import {
|
||||
globalNewLLMConfigsAtom,
|
||||
llmPreferencesAtom,
|
||||
newLLMConfigsAtom,
|
||||
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { ComposerAddAttachment } from "@/components/assistant-ui/attachment";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
|
||||
const ConnectorIndicator: FC = () => {
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const { connectors, isLoading: connectorsLoading } = useSearchSourceConnectors(
|
||||
false,
|
||||
searchSpaceId ? Number(searchSpaceId) : undefined
|
||||
);
|
||||
const { data: documentTypeCounts, isLoading: documentTypesLoading } =
|
||||
useAtomValue(documentTypeCountsAtom);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const isLoading = connectorsLoading || documentTypesLoading;
|
||||
|
||||
const activeDocumentTypes = documentTypeCounts
|
||||
? Object.entries(documentTypeCounts).filter(([_, count]) => count > 0)
|
||||
: [];
|
||||
|
||||
const nonIndexableConnectors = connectors.filter((connector) => !connector.is_indexable);
|
||||
|
||||
const hasConnectors = nonIndexableConnectors.length > 0;
|
||||
const hasSources = hasConnectors || activeDocumentTypes.length > 0;
|
||||
const totalSourceCount = nonIndexableConnectors.length + activeDocumentTypes.length;
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
// Clear any pending close timeout
|
||||
if (closeTimeoutRef.current) {
|
||||
clearTimeout(closeTimeoutRef.current);
|
||||
closeTimeoutRef.current = null;
|
||||
}
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
// Delay closing by 150ms for better UX
|
||||
closeTimeoutRef.current = setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 150);
|
||||
}, []);
|
||||
|
||||
if (!searchSpaceId) return null;
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"size-[34px] rounded-full p-1 flex items-center justify-center transition-colors relative",
|
||||
"hover:bg-muted-foreground/15 dark:hover:bg-muted-foreground/30",
|
||||
"outline-none focus:outline-none focus-visible:outline-none",
|
||||
"border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none",
|
||||
"data-[state=open]:bg-transparent data-[state=open]:shadow-none data-[state=open]:ring-0",
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
aria-label={
|
||||
hasSources ? `View ${totalSourceCount} connected sources` : "Add your first connector"
|
||||
}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Plug2 className="size-4" />
|
||||
{totalSourceCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-medium rounded-full bg-primary text-primary-foreground shadow-sm">
|
||||
{totalSourceCount > 99 ? "99+" : totalSourceCount}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="w-64 p-3"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{hasSources ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-muted-foreground">Connected Sources</p>
|
||||
<span className="text-xs font-medium bg-muted px-1.5 py-0.5 rounded">
|
||||
{totalSourceCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{activeDocumentTypes.map(([docType, count]) => (
|
||||
<div
|
||||
key={docType}
|
||||
className="flex items-center gap-1.5 rounded-md bg-muted/80 px-2.5 py-1.5 text-xs border border-border/50"
|
||||
>
|
||||
{getConnectorIcon(docType, "size-3.5")}
|
||||
<span className="truncate max-w-[100px]">{getDocumentTypeLabel(docType)}</span>
|
||||
<span className="flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-medium rounded-full bg-primary/10 text-primary">
|
||||
{count > 999 ? "999+" : count}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{nonIndexableConnectors.map((connector) => (
|
||||
<div
|
||||
key={`connector-${connector.id}`}
|
||||
className="flex items-center gap-1.5 rounded-md bg-muted/80 px-2.5 py-1.5 text-xs border border-border/50"
|
||||
>
|
||||
{getConnectorIcon(connector.connector_type, "size-3.5")}
|
||||
<span className="truncate max-w-[100px]">{connector.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="pt-1 border-t border-border/50">
|
||||
<Link
|
||||
href={`/dashboard/${searchSpaceId}/connectors/add`}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
Add more sources
|
||||
<ChevronRightIcon className="size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">No sources yet</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add documents or connect data sources to enhance search results.
|
||||
</p>
|
||||
<Link
|
||||
href={`/dashboard/${searchSpaceId}/connectors/add`}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors mt-1"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
Add Connector
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export const ComposerAction: FC = () => {
|
||||
// Check if any attachments are still being processed (running AND progress < 100)
|
||||
// When progress is 100, processing is done but waiting for send()
|
||||
const hasProcessingAttachments = useAssistantState(({ composer }) =>
|
||||
composer.attachments?.some((att) => {
|
||||
const status = att.status;
|
||||
if (status?.type !== "running") return false;
|
||||
const progress = (status as { type: "running"; progress?: number }).progress;
|
||||
return progress === undefined || progress < 100;
|
||||
})
|
||||
);
|
||||
|
||||
// Check if composer text is empty
|
||||
const isComposerEmpty = useAssistantState(({ composer }) => {
|
||||
const text = composer.text?.trim() || "";
|
||||
return text.length === 0;
|
||||
});
|
||||
|
||||
// Check if a model is configured
|
||||
const { data: userConfigs } = useAtomValue(newLLMConfigsAtom);
|
||||
const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom);
|
||||
const { data: preferences } = useAtomValue(llmPreferencesAtom);
|
||||
|
||||
const hasModelConfigured = useMemo(() => {
|
||||
if (!preferences) return false;
|
||||
const agentLlmId = preferences.agent_llm_id;
|
||||
if (agentLlmId === null || agentLlmId === undefined) return false;
|
||||
|
||||
// Check if the configured model actually exists
|
||||
if (agentLlmId < 0) {
|
||||
return globalConfigs?.some((c) => c.id === agentLlmId) ?? false;
|
||||
}
|
||||
return userConfigs?.some((c) => c.id === agentLlmId) ?? false;
|
||||
}, [preferences, globalConfigs, userConfigs]);
|
||||
|
||||
const isSendDisabled = hasProcessingAttachments || isComposerEmpty || !hasModelConfigured;
|
||||
|
||||
return (
|
||||
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<ComposerAddAttachment />
|
||||
<ConnectorIndicator />
|
||||
</div>
|
||||
|
||||
{/* Show processing indicator when attachments are being processed */}
|
||||
{hasProcessingAttachments && (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
<span>Processing...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show warning when no model is configured */}
|
||||
{!hasModelConfigured && !hasProcessingAttachments && (
|
||||
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400 text-xs">
|
||||
<AlertCircle className="size-3" />
|
||||
<span>Select a model</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AssistantIf condition={({ thread }) => !thread.isRunning}>
|
||||
<ComposerPrimitive.Send asChild disabled={isSendDisabled}>
|
||||
<TooltipIconButton
|
||||
tooltip={
|
||||
!hasModelConfigured
|
||||
? "Please select a model from the header to start chatting"
|
||||
: hasProcessingAttachments
|
||||
? "Wait for attachments to process"
|
||||
: isComposerEmpty
|
||||
? "Enter a message to send"
|
||||
: "Send message"
|
||||
}
|
||||
side="bottom"
|
||||
type="submit"
|
||||
variant="default"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"aui-composer-send size-8 rounded-full",
|
||||
isSendDisabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
aria-label="Send message"
|
||||
disabled={isSendDisabled}
|
||||
>
|
||||
<ArrowUpIcon className="aui-composer-send-icon size-4" />
|
||||
</TooltipIconButton>
|
||||
</ComposerPrimitive.Send>
|
||||
</AssistantIf>
|
||||
|
||||
<AssistantIf condition={({ thread }) => thread.isRunning}>
|
||||
<ComposerPrimitive.Cancel asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="icon"
|
||||
className="aui-composer-cancel size-8 rounded-full"
|
||||
aria-label="Stop generating"
|
||||
>
|
||||
<SquareIcon className="aui-composer-cancel-icon size-3 fill-current" />
|
||||
</Button>
|
||||
</ComposerPrimitive.Cancel>
|
||||
</AssistantIf>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
240
surfsense_web/components/assistant-ui/composer.tsx
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import { ComposerPrimitive, useAssistantState, useComposerRuntime } from "@assistant-ui/react";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { useParams } from "next/navigation";
|
||||
import type { FC } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
mentionedDocumentIdsAtom,
|
||||
mentionedDocumentsAtom,
|
||||
} from "@/atoms/chat/mentioned-documents.atom";
|
||||
import {
|
||||
ComposerAddAttachment,
|
||||
ComposerAttachments,
|
||||
} from "@/components/assistant-ui/attachment";
|
||||
import { ComposerAction } from "@/components/assistant-ui/composer-action";
|
||||
import {
|
||||
InlineMentionEditor,
|
||||
type InlineMentionEditorRef,
|
||||
} from "@/components/assistant-ui/inline-mention-editor";
|
||||
import {
|
||||
DocumentMentionPicker,
|
||||
type DocumentMentionPickerRef,
|
||||
} from "@/components/new-chat/document-mention-picker";
|
||||
import type { Document } from "@/contracts/types/document.types";
|
||||
|
||||
export const Composer: FC = () => {
|
||||
// ---- State for document mentions (using atoms to persist across remounts) ----
|
||||
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
|
||||
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
|
||||
const [mentionQuery, setMentionQuery] = useState("");
|
||||
const editorRef = useRef<InlineMentionEditorRef>(null);
|
||||
const editorContainerRef = useRef<HTMLDivElement>(null);
|
||||
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
|
||||
const { search_space_id } = useParams();
|
||||
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom);
|
||||
const composerRuntime = useComposerRuntime();
|
||||
const hasAutoFocusedRef = useRef(false);
|
||||
|
||||
// Check if thread is empty (new chat)
|
||||
const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty);
|
||||
|
||||
// Check if thread is currently running (streaming response)
|
||||
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
|
||||
|
||||
// Auto-focus editor when on new chat page
|
||||
useEffect(() => {
|
||||
if (isThreadEmpty && !hasAutoFocusedRef.current && editorRef.current) {
|
||||
// Small delay to ensure the editor is fully mounted
|
||||
const timeoutId = setTimeout(() => {
|
||||
editorRef.current?.focus();
|
||||
hasAutoFocusedRef.current = true;
|
||||
}, 100);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [isThreadEmpty]);
|
||||
|
||||
// Sync mentioned document IDs to atom for use in chat request
|
||||
useEffect(() => {
|
||||
setMentionedDocumentIds(mentionedDocuments.map((doc) => doc.id));
|
||||
}, [mentionedDocuments, setMentionedDocumentIds]);
|
||||
|
||||
// Handle text change from inline editor - sync with assistant-ui composer
|
||||
const handleEditorChange = useCallback(
|
||||
(text: string) => {
|
||||
composerRuntime.setText(text);
|
||||
},
|
||||
[composerRuntime]
|
||||
);
|
||||
|
||||
// Handle @ mention trigger from inline editor
|
||||
const handleMentionTrigger = useCallback((query: string) => {
|
||||
setShowDocumentPopover(true);
|
||||
setMentionQuery(query);
|
||||
}, []);
|
||||
|
||||
// Handle mention close
|
||||
const handleMentionClose = useCallback(() => {
|
||||
if (showDocumentPopover) {
|
||||
setShowDocumentPopover(false);
|
||||
setMentionQuery("");
|
||||
}
|
||||
}, [showDocumentPopover]);
|
||||
|
||||
// Handle keyboard navigation when popover is open
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (showDocumentPopover) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
documentPickerRef.current?.moveDown();
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
documentPickerRef.current?.moveUp();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
documentPickerRef.current?.selectHighlighted();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setShowDocumentPopover(false);
|
||||
setMentionQuery("");
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
[showDocumentPopover]
|
||||
);
|
||||
|
||||
// Handle submit from inline editor (Enter key)
|
||||
const handleSubmit = useCallback(() => {
|
||||
// Prevent sending while a response is still streaming
|
||||
if (isThreadRunning) {
|
||||
return;
|
||||
}
|
||||
if (!showDocumentPopover) {
|
||||
composerRuntime.send();
|
||||
// Clear the editor after sending
|
||||
editorRef.current?.clear();
|
||||
setMentionedDocuments([]);
|
||||
setMentionedDocumentIds([]);
|
||||
}
|
||||
}, [
|
||||
showDocumentPopover,
|
||||
isThreadRunning,
|
||||
composerRuntime,
|
||||
setMentionedDocuments,
|
||||
setMentionedDocumentIds,
|
||||
]);
|
||||
|
||||
// Handle document removal from inline editor
|
||||
const handleDocumentRemove = useCallback(
|
||||
(docId: number) => {
|
||||
setMentionedDocuments((prev) => {
|
||||
const updated = prev.filter((doc) => doc.id !== docId);
|
||||
// Immediately sync document IDs to avoid race conditions
|
||||
setMentionedDocumentIds(updated.map((doc) => doc.id));
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
[setMentionedDocuments, setMentionedDocumentIds]
|
||||
);
|
||||
|
||||
// Handle document selection from picker
|
||||
const handleDocumentsMention = useCallback(
|
||||
(documents: Document[]) => {
|
||||
// Insert chips into the inline editor for each new document
|
||||
const existingIds = new Set(mentionedDocuments.map((d) => d.id));
|
||||
const newDocs = documents.filter((doc) => !existingIds.has(doc.id));
|
||||
|
||||
for (const doc of newDocs) {
|
||||
editorRef.current?.insertDocumentChip(doc);
|
||||
}
|
||||
|
||||
// Update mentioned documents state
|
||||
setMentionedDocuments((prev) => {
|
||||
const existingIdSet = new Set(prev.map((d) => d.id));
|
||||
const uniqueNewDocs = documents.filter((doc) => !existingIdSet.has(doc.id));
|
||||
const updated = [...prev, ...uniqueNewDocs];
|
||||
// Immediately sync document IDs to avoid race conditions
|
||||
setMentionedDocumentIds(updated.map((doc) => doc.id));
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Reset mention query but keep popover open for more selections
|
||||
setMentionQuery("");
|
||||
},
|
||||
[mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds]
|
||||
);
|
||||
|
||||
return (
|
||||
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">
|
||||
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone flex w-full flex-col rounded-2xl border-input bg-muted px-1 pt-2 outline-none transition-shadow data-[dragging=true]:border-ring data-[dragging=true]:border-dashed data-[dragging=true]:bg-accent/50">
|
||||
<ComposerAttachments />
|
||||
{/* -------- Inline Mention Editor -------- */}
|
||||
<div ref={editorContainerRef} className="aui-composer-input-wrapper px-3 pt-3 pb-6">
|
||||
<InlineMentionEditor
|
||||
ref={editorRef}
|
||||
placeholder="Ask SurfSense or @mention docs"
|
||||
onMentionTrigger={handleMentionTrigger}
|
||||
onMentionClose={handleMentionClose}
|
||||
onChange={handleEditorChange}
|
||||
onDocumentRemove={handleDocumentRemove}
|
||||
onSubmit={handleSubmit}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="min-h-[24px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* -------- Document mention popover (rendered via portal) -------- */}
|
||||
{showDocumentPopover &&
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<button
|
||||
type="button"
|
||||
className="fixed inset-0 cursor-default"
|
||||
style={{ zIndex: 9998 }}
|
||||
onClick={() => setShowDocumentPopover(false)}
|
||||
aria-label="Close document picker"
|
||||
/>
|
||||
{/* Popover positioned above input */}
|
||||
<div
|
||||
className="fixed shadow-2xl rounded-lg border border-border overflow-hidden bg-popover"
|
||||
style={{
|
||||
zIndex: 9999,
|
||||
bottom: editorContainerRef.current
|
||||
? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 8}px`
|
||||
: "200px",
|
||||
left: editorContainerRef.current
|
||||
? `${editorContainerRef.current.getBoundingClientRect().left}px`
|
||||
: "50%",
|
||||
}}
|
||||
>
|
||||
<DocumentMentionPicker
|
||||
ref={documentPickerRef}
|
||||
searchSpaceId={Number(search_space_id)}
|
||||
onSelectionChange={handleDocumentsMention}
|
||||
onDone={() => {
|
||||
setShowDocumentPopover(false);
|
||||
setMentionQuery("");
|
||||
}}
|
||||
initialSelectedDocuments={mentionedDocuments}
|
||||
externalSearch={mentionQuery}
|
||||
/>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
<ComposerAction />
|
||||
</ComposerPrimitive.AttachmentDropzone>
|
||||
</ComposerPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
27
surfsense_web/components/assistant-ui/edit-composer.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { ComposerPrimitive, MessagePrimitive } from "@assistant-ui/react";
|
||||
import type { FC } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export const EditComposer: FC = () => {
|
||||
return (
|
||||
<MessagePrimitive.Root className="aui-edit-composer-wrapper mx-auto flex w-full max-w-(--thread-max-width) flex-col px-2 py-3">
|
||||
<ComposerPrimitive.Root className="aui-edit-composer-root ml-auto flex w-full max-w-[85%] flex-col rounded-2xl bg-muted">
|
||||
<ComposerPrimitive.Input
|
||||
className="aui-edit-composer-input min-h-14 w-full resize-none bg-transparent p-4 text-foreground text-sm outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="aui-edit-composer-footer mx-3 mb-3 flex items-center gap-2 self-end">
|
||||
<ComposerPrimitive.Cancel asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
Cancel
|
||||
</Button>
|
||||
</ComposerPrimitive.Cancel>
|
||||
<ComposerPrimitive.Send asChild>
|
||||
<Button size="sm">Update</Button>
|
||||
</ComposerPrimitive.Send>
|
||||
</div>
|
||||
</ComposerPrimitive.Root>
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
207
surfsense_web/components/assistant-ui/thinking-steps.tsx
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import { useAssistantState, useThreadViewport } from "@assistant-ui/react";
|
||||
import type { FC } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import { ChainOfThoughtItem } from "@/components/prompt-kit/chain-of-thought";
|
||||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Context to pass thinking steps to AssistantMessage
|
||||
export const ThinkingStepsContext = createContext<Map<string, ThinkingStep[]>>(new Map());
|
||||
|
||||
/**
|
||||
* Chain of thought display component - single collapsible dropdown design
|
||||
*/
|
||||
export const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolean }> = ({
|
||||
steps,
|
||||
isThreadRunning = true,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
// Derive effective status for each step
|
||||
const getEffectiveStatus = useCallback(
|
||||
(step: ThinkingStep): "pending" | "in_progress" | "completed" => {
|
||||
if (step.status === "in_progress" && !isThreadRunning) {
|
||||
return "completed";
|
||||
}
|
||||
return step.status;
|
||||
},
|
||||
[isThreadRunning]
|
||||
);
|
||||
|
||||
// Calculate summary info
|
||||
const completedSteps = steps.filter((s) => getEffectiveStatus(s) === "completed").length;
|
||||
const inProgressStep = steps.find((s) => getEffectiveStatus(s) === "in_progress");
|
||||
const allCompleted = completedSteps === steps.length && steps.length > 0 && !isThreadRunning;
|
||||
const isProcessing = isThreadRunning && !allCompleted;
|
||||
|
||||
// Auto-collapse when all tasks are completed
|
||||
useEffect(() => {
|
||||
if (allCompleted) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [allCompleted]);
|
||||
|
||||
if (steps.length === 0) return null;
|
||||
|
||||
// Generate header text
|
||||
const getHeaderText = () => {
|
||||
if (allCompleted) {
|
||||
return `Reviewed ${completedSteps} ${completedSteps === 1 ? "step" : "steps"}`;
|
||||
}
|
||||
if (inProgressStep) {
|
||||
return inProgressStep.title;
|
||||
}
|
||||
if (isProcessing) {
|
||||
return `Processing ${completedSteps}/${steps.length} steps`;
|
||||
}
|
||||
return `Reviewed ${completedSteps} ${completedSteps === 1 ? "step" : "steps"}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-(--thread-max-width) px-2 py-2">
|
||||
<div className="rounded-lg">
|
||||
{/* Main collapsible header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-1.5 text-left text-sm transition-colors",
|
||||
"text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{/* Header text with shimmer if processing (streaming) */}
|
||||
{isProcessing ? (
|
||||
<TextShimmerLoader text={getHeaderText()} size="sm" />
|
||||
) : (
|
||||
<span>{getHeaderText()}</span>
|
||||
)}
|
||||
|
||||
{/* Chevron */}
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4 transition-transform duration-200", isOpen && "rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Collapsible content with CSS grid animation */}
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-[grid-template-rows] duration-300 ease-out",
|
||||
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="mt-3 pl-1">
|
||||
{steps.map((step, index) => {
|
||||
const effectiveStatus = getEffectiveStatus(step);
|
||||
const isLast = index === steps.length - 1;
|
||||
|
||||
return (
|
||||
<div key={step.id} className="relative flex gap-3">
|
||||
{/* Dot and line column */}
|
||||
<div className="relative flex flex-col items-center w-2">
|
||||
{/* Vertical connection line - extends to next dot */}
|
||||
{!isLast && (
|
||||
<div className="absolute left-1/2 top-[15px] -bottom-[7px] w-px -translate-x-1/2 bg-muted-foreground/30" />
|
||||
)}
|
||||
{/* Step dot - on top of line */}
|
||||
<div className="relative z-10 mt-[7px] flex shrink-0 items-center justify-center">
|
||||
{effectiveStatus === "in_progress" ? (
|
||||
<span className="size-2 rounded-full bg-muted-foreground/30" />
|
||||
) : (
|
||||
<span className="size-2 rounded-full bg-muted-foreground/30" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<div className="flex-1 min-w-0 pb-4">
|
||||
{/* Step title */}
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm leading-5",
|
||||
effectiveStatus === "in_progress" && "text-foreground font-medium",
|
||||
effectiveStatus === "completed" && "text-muted-foreground",
|
||||
effectiveStatus === "pending" && "text-muted-foreground/60"
|
||||
)}
|
||||
>
|
||||
{effectiveStatus === "in_progress" ? (
|
||||
<TextShimmerLoader text={step.title} size="sm" />
|
||||
) : (
|
||||
step.title
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step items (sub-content) */}
|
||||
{step.items && step.items.length > 0 && (
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{step.items.map((item, idx) => (
|
||||
<ChainOfThoughtItem key={`${step.id}-item-${idx}`} className="text-xs">
|
||||
{item}
|
||||
</ChainOfThoughtItem>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Component that handles auto-scroll when thinking steps update.
|
||||
* Uses useThreadViewport to scroll to bottom when thinking steps change,
|
||||
* ensuring the user always sees the latest content during streaming.
|
||||
*/
|
||||
export const ThinkingStepsScrollHandler: FC = () => {
|
||||
const thinkingStepsMap = useContext(ThinkingStepsContext);
|
||||
const viewport = useThreadViewport();
|
||||
const isRunning = useAssistantState(({ thread }) => thread.isRunning);
|
||||
// Track the serialized state to detect any changes
|
||||
const prevStateRef = useRef<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
// Only act during streaming
|
||||
if (!isRunning) {
|
||||
prevStateRef.current = "";
|
||||
return;
|
||||
}
|
||||
|
||||
// Serialize the thinking steps state to detect any changes
|
||||
// This catches new steps, status changes, and item additions
|
||||
let stateString = "";
|
||||
thinkingStepsMap.forEach((steps, msgId) => {
|
||||
steps.forEach((step) => {
|
||||
stateString += `${msgId}:${step.id}:${step.status}:${step.items?.length || 0};`;
|
||||
});
|
||||
});
|
||||
|
||||
// If state changed at all during streaming, scroll
|
||||
if (stateString !== prevStateRef.current && stateString !== "") {
|
||||
prevStateRef.current = stateString;
|
||||
|
||||
// Multiple attempts to ensure scroll happens after DOM updates
|
||||
const scrollAttempt = () => {
|
||||
try {
|
||||
viewport.scrollToBottom();
|
||||
} catch {
|
||||
// Ignore errors - viewport might not be ready
|
||||
}
|
||||
};
|
||||
|
||||
// Delayed attempts to handle async DOM updates
|
||||
requestAnimationFrame(scrollAttempt);
|
||||
setTimeout(scrollAttempt, 100);
|
||||
}
|
||||
}, [thinkingStepsMap, viewport, isRunning]);
|
||||
|
||||
return null; // This component doesn't render anything
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { ThreadPrimitive } from "@assistant-ui/react";
|
||||
import { ArrowDownIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
|
||||
export const ThreadScrollToBottom: FC = () => {
|
||||
return (
|
||||
<ThreadPrimitive.ScrollToBottom asChild>
|
||||
<TooltipIconButton
|
||||
tooltip="Scroll to bottom"
|
||||
variant="outline"
|
||||
className="aui-thread-scroll-to-bottom -top-12 absolute z-10 self-center rounded-full p-4 disabled:invisible dark:bg-background dark:hover:bg-accent"
|
||||
>
|
||||
<ArrowDownIcon />
|
||||
</TooltipIconButton>
|
||||
</ThreadPrimitive.ScrollToBottom>
|
||||
);
|
||||
};
|
||||
|
||||
72
surfsense_web/components/assistant-ui/thread-welcome.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { useAtomValue } from "jotai";
|
||||
import type { FC } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { Composer } from "@/components/assistant-ui/composer";
|
||||
|
||||
const getTimeBasedGreeting = (userEmail?: string): string => {
|
||||
const hour = new Date().getHours();
|
||||
|
||||
// Extract first name from email if available
|
||||
const firstName = userEmail
|
||||
? userEmail.split("@")[0].split(".")[0].charAt(0).toUpperCase() +
|
||||
userEmail.split("@")[0].split(".")[0].slice(1)
|
||||
: null;
|
||||
|
||||
// Array of greeting variations for each time period
|
||||
const morningGreetings = ["Good morning", "Fresh start today", "Morning", "Hey there"];
|
||||
|
||||
const afternoonGreetings = ["Good afternoon", "Afternoon", "Hey there", "Hi there"];
|
||||
|
||||
const eveningGreetings = ["Good evening", "Evening", "Hey there", "Hi there"];
|
||||
|
||||
const nightGreetings = ["Good night", "Evening", "Hey there", "Winding down"];
|
||||
|
||||
const lateNightGreetings = ["Still up", "Night owl mode", "Up past bedtime", "Hi there"];
|
||||
|
||||
// Select a random greeting based on time
|
||||
let greeting: string;
|
||||
if (hour < 5) {
|
||||
// Late night: midnight to 5 AM
|
||||
greeting = lateNightGreetings[Math.floor(Math.random() * lateNightGreetings.length)];
|
||||
} else if (hour < 12) {
|
||||
greeting = morningGreetings[Math.floor(Math.random() * morningGreetings.length)];
|
||||
} else if (hour < 18) {
|
||||
greeting = afternoonGreetings[Math.floor(Math.random() * afternoonGreetings.length)];
|
||||
} else if (hour < 22) {
|
||||
greeting = eveningGreetings[Math.floor(Math.random() * eveningGreetings.length)];
|
||||
} else {
|
||||
// Night: 10 PM to midnight
|
||||
greeting = nightGreetings[Math.floor(Math.random() * nightGreetings.length)];
|
||||
}
|
||||
|
||||
// Add personalization with first name if available
|
||||
if (firstName) {
|
||||
return `${greeting}, ${firstName}!`;
|
||||
}
|
||||
|
||||
return `${greeting}!`;
|
||||
};
|
||||
|
||||
export const ThreadWelcome: FC = () => {
|
||||
const { data: user } = useAtomValue(currentUserAtom);
|
||||
|
||||
// Memoize greeting so it doesn't change on re-renders (only on user change)
|
||||
const greeting = useMemo(() => getTimeBasedGreeting(user?.email), [user?.email]);
|
||||
|
||||
return (
|
||||
<div className="aui-thread-welcome-root mx-auto flex w-full max-w-(--thread-max-width) grow flex-col items-center px-4 relative">
|
||||
{/* Greeting positioned above the composer - fixed position */}
|
||||
<div className="aui-thread-welcome-message absolute bottom-[calc(50%+5rem)] left-0 right-0 flex flex-col items-center text-center">
|
||||
<h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-2 animate-in text-3xl md:text-5xl delay-100 duration-500 ease-out fill-mode-both">
|
||||
{greeting}
|
||||
</h1>
|
||||
</div>
|
||||
{/* Composer - top edge fixed, expands downward only */}
|
||||
<div className="fade-in slide-in-from-bottom-3 animate-in delay-200 duration-500 ease-out fill-mode-both w-full flex items-start justify-center absolute top-[calc(50%-3.5rem)] left-0 right-0">
|
||||
<Composer />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { AssistantIf, ThreadPrimitive } from "@assistant-ui/react";
|
||||
import type { FC } from "react";
|
||||
import {
|
||||
ActionBarPrimitive,
|
||||
AssistantIf,
|
||||
|
|
@ -70,6 +72,13 @@ import {
|
|||
import { ChainOfThoughtItem } from "@/components/prompt-kit/chain-of-thought";
|
||||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
||||
import { ThinkingStepsContext } from "@/components/assistant-ui/thinking-steps";
|
||||
import { ThreadWelcome } from "@/components/assistant-ui/thread-welcome";
|
||||
import { Composer } from "@/components/assistant-ui/composer";
|
||||
import { ThreadScrollToBottom } from "@/components/assistant-ui/thread-scroll-to-bottom";
|
||||
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
|
||||
import { UserMessage } from "@/components/assistant-ui/user-message";
|
||||
import { EditComposer } from "@/components/assistant-ui/edit-composer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Document } from "@/contracts/types/document.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -83,204 +92,6 @@ interface ThreadProps {
|
|||
header?: React.ReactNode;
|
||||
}
|
||||
|
||||
// Context to pass thinking steps to AssistantMessage
|
||||
const ThinkingStepsContext = createContext<Map<string, ThinkingStep[]>>(new Map());
|
||||
|
||||
/**
|
||||
* Chain of thought display component - single collapsible dropdown design
|
||||
*/
|
||||
const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolean }> = ({
|
||||
steps,
|
||||
isThreadRunning = true,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
// Derive effective status for each step
|
||||
const getEffectiveStatus = useCallback(
|
||||
(step: ThinkingStep): "pending" | "in_progress" | "completed" => {
|
||||
if (step.status === "in_progress" && !isThreadRunning) {
|
||||
return "completed";
|
||||
}
|
||||
return step.status;
|
||||
},
|
||||
[isThreadRunning]
|
||||
);
|
||||
|
||||
// Calculate summary info
|
||||
const completedSteps = steps.filter((s) => getEffectiveStatus(s) === "completed").length;
|
||||
const inProgressStep = steps.find((s) => getEffectiveStatus(s) === "in_progress");
|
||||
const allCompleted = completedSteps === steps.length && steps.length > 0 && !isThreadRunning;
|
||||
const isProcessing = isThreadRunning && !allCompleted;
|
||||
|
||||
// Auto-collapse when all tasks are completed
|
||||
useEffect(() => {
|
||||
if (allCompleted) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [allCompleted]);
|
||||
|
||||
if (steps.length === 0) return null;
|
||||
|
||||
// Generate header text
|
||||
const getHeaderText = () => {
|
||||
if (allCompleted) {
|
||||
return `Reviewed ${completedSteps} ${completedSteps === 1 ? "step" : "steps"}`;
|
||||
}
|
||||
if (inProgressStep) {
|
||||
return inProgressStep.title;
|
||||
}
|
||||
if (isProcessing) {
|
||||
return `Processing ${completedSteps}/${steps.length} steps`;
|
||||
}
|
||||
return `Reviewed ${completedSteps} ${completedSteps === 1 ? "step" : "steps"}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-(--thread-max-width) px-2 py-2">
|
||||
<div className="rounded-lg">
|
||||
{/* Main collapsible header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-1.5 text-left text-sm transition-colors",
|
||||
"text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{/* Header text with shimmer if processing (streaming) */}
|
||||
{isProcessing ? (
|
||||
<TextShimmerLoader text={getHeaderText()} size="sm" />
|
||||
) : (
|
||||
<span>{getHeaderText()}</span>
|
||||
)}
|
||||
|
||||
{/* Chevron */}
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4 transition-transform duration-200", isOpen && "rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Collapsible content with CSS grid animation */}
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-[grid-template-rows] duration-300 ease-out",
|
||||
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="mt-3 pl-1">
|
||||
{steps.map((step, index) => {
|
||||
const effectiveStatus = getEffectiveStatus(step);
|
||||
const isLast = index === steps.length - 1;
|
||||
|
||||
return (
|
||||
<div key={step.id} className="relative flex gap-3">
|
||||
{/* Dot and line column */}
|
||||
<div className="relative flex flex-col items-center w-2">
|
||||
{/* Vertical connection line - extends to next dot */}
|
||||
{!isLast && (
|
||||
<div className="absolute left-1/2 top-[15px] -bottom-[7px] w-px -translate-x-1/2 bg-muted-foreground/30" />
|
||||
)}
|
||||
{/* Step dot - on top of line */}
|
||||
<div className="relative z-10 mt-[7px] flex shrink-0 items-center justify-center">
|
||||
{effectiveStatus === "in_progress" ? (
|
||||
<span className="size-2 rounded-full bg-muted-foreground/30" />
|
||||
) : (
|
||||
<span className="size-2 rounded-full bg-muted-foreground/30" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<div className="flex-1 min-w-0 pb-4">
|
||||
{/* Step title */}
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm leading-5",
|
||||
effectiveStatus === "in_progress" && "text-foreground font-medium",
|
||||
effectiveStatus === "completed" && "text-muted-foreground",
|
||||
effectiveStatus === "pending" && "text-muted-foreground/60"
|
||||
)}
|
||||
>
|
||||
{effectiveStatus === "in_progress" ? (
|
||||
<TextShimmerLoader text={step.title} size="sm" />
|
||||
) : (
|
||||
step.title
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step items (sub-content) */}
|
||||
{step.items && step.items.length > 0 && (
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{step.items.map((item, idx) => (
|
||||
<ChainOfThoughtItem key={`${step.id}-item-${idx}`} className="text-xs">
|
||||
{item}
|
||||
</ChainOfThoughtItem>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Component that handles auto-scroll when thinking steps update.
|
||||
* Uses useThreadViewport to scroll to bottom when thinking steps change,
|
||||
* ensuring the user always sees the latest content during streaming.
|
||||
*/
|
||||
const _ThinkingStepsScrollHandler: FC = () => {
|
||||
const thinkingStepsMap = useContext(ThinkingStepsContext);
|
||||
const viewport = useThreadViewport();
|
||||
const isRunning = useAssistantState(({ thread }) => thread.isRunning);
|
||||
// Track the serialized state to detect any changes
|
||||
const prevStateRef = useRef<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
// Only act during streaming
|
||||
if (!isRunning) {
|
||||
prevStateRef.current = "";
|
||||
return;
|
||||
}
|
||||
|
||||
// Serialize the thinking steps state to detect any changes
|
||||
// This catches new steps, status changes, and item additions
|
||||
let stateString = "";
|
||||
thinkingStepsMap.forEach((steps, msgId) => {
|
||||
steps.forEach((step) => {
|
||||
stateString += `${msgId}:${step.id}:${step.status}:${step.items?.length || 0};`;
|
||||
});
|
||||
});
|
||||
|
||||
// If state changed at all during streaming, scroll
|
||||
if (stateString !== prevStateRef.current && stateString !== "") {
|
||||
prevStateRef.current = stateString;
|
||||
|
||||
// Multiple attempts to ensure scroll happens after DOM updates
|
||||
const scrollAttempt = () => {
|
||||
try {
|
||||
viewport.scrollToBottom();
|
||||
} catch {
|
||||
// Ignore errors - viewport might not be ready
|
||||
}
|
||||
};
|
||||
|
||||
// Delayed attempts to handle async DOM updates
|
||||
requestAnimationFrame(scrollAttempt);
|
||||
setTimeout(scrollAttempt, 100);
|
||||
}
|
||||
}, [thinkingStepsMap, viewport, isRunning]);
|
||||
|
||||
return null; // This component doesn't render anything
|
||||
};
|
||||
|
||||
export const Thread: FC<ThreadProps> = ({ messageThinkingSteps = new Map(), header }) => {
|
||||
return (
|
||||
<ThinkingStepsContext.Provider value={messageThinkingSteps}>
|
||||
|
|
|
|||
73
surfsense_web/components/assistant-ui/user-message.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { ActionBarPrimitive, MessagePrimitive, useAssistantState } from "@assistant-ui/react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { FileText, PencilIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom";
|
||||
import { UserMessageAttachments } from "@/components/assistant-ui/attachment";
|
||||
import { BranchPicker } from "@/components/assistant-ui/branch-picker";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
|
||||
export const UserMessage: FC = () => {
|
||||
const messageId = useAssistantState(({ message }) => message?.id);
|
||||
const messageDocumentsMap = useAtomValue(messageDocumentsMapAtom);
|
||||
const mentionedDocs = messageId ? messageDocumentsMap[messageId] : undefined;
|
||||
const hasAttachments = useAssistantState(
|
||||
({ message }) => message?.attachments && message.attachments.length > 0
|
||||
);
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="aui-user-message-root fade-in slide-in-from-bottom-1 mx-auto grid w-full max-w-(--thread-max-width) animate-in auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 py-3 duration-150 [&:where(>*)]:col-start-2"
|
||||
data-role="user"
|
||||
>
|
||||
<div className="aui-user-message-content-wrapper col-start-2 min-w-0">
|
||||
{/* Display attachments and mentioned documents */}
|
||||
{(hasAttachments || (mentionedDocs && mentionedDocs.length > 0)) && (
|
||||
<div className="flex flex-wrap items-end gap-2 mb-2 justify-end">
|
||||
{/* Attachments (images show as thumbnails, documents as chips) */}
|
||||
<UserMessageAttachments />
|
||||
{/* Mentioned documents as chips */}
|
||||
{mentionedDocs?.map((doc) => (
|
||||
<span
|
||||
key={doc.id}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-xs font-medium text-primary border border-primary/20"
|
||||
title={doc.title}
|
||||
>
|
||||
<FileText className="size-3" />
|
||||
<span className="max-w-[150px] truncate">{doc.title}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Message bubble with action bar positioned relative to it */}
|
||||
<div className="relative">
|
||||
<div className="aui-user-message-content wrap-break-word rounded-2xl bg-muted px-4 py-2.5 text-foreground">
|
||||
<MessagePrimitive.Parts />
|
||||
</div>
|
||||
<div className="aui-user-action-bar-wrapper absolute top-1/2 right-full -translate-y-1/2 pr-1">
|
||||
<UserActionBar />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BranchPicker className="aui-user-branch-picker -mr-1 col-span-full col-start-1 row-start-3 justify-end" />
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const UserActionBar: FC = () => {
|
||||
return (
|
||||
<ActionBarPrimitive.Root
|
||||
hideWhenRunning
|
||||
autohide="not-last"
|
||||
className="aui-user-action-bar-root flex flex-col items-end"
|
||||
>
|
||||
<ActionBarPrimitive.Edit asChild>
|
||||
<TooltipIconButton tooltip="Edit" className="aui-user-action-edit p-4">
|
||||
<PencilIcon />
|
||||
</TooltipIconButton>
|
||||
</ActionBarPrimitive.Edit>
|
||||
</ActionBarPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -46,6 +46,8 @@ interface GoogleDriveFolderTreeProps {
|
|||
connectorId: number;
|
||||
selectedFolders: SelectedFolder[];
|
||||
onSelectFolders: (folders: SelectedFolder[]) => void;
|
||||
selectedFiles?: SelectedFolder[];
|
||||
onSelectFiles?: (files: SelectedFolder[]) => void;
|
||||
}
|
||||
|
||||
// Helper to get appropriate icon for file type
|
||||
|
|
@ -69,6 +71,8 @@ export function GoogleDriveFolderTree({
|
|||
connectorId,
|
||||
selectedFolders,
|
||||
onSelectFolders,
|
||||
selectedFiles = [],
|
||||
onSelectFiles = () => {},
|
||||
}: GoogleDriveFolderTreeProps) {
|
||||
const [itemStates, setItemStates] = useState<Map<string, ItemTreeNode>>(new Map());
|
||||
|
||||
|
|
@ -82,6 +86,10 @@ export function GoogleDriveFolderTree({
|
|||
return selectedFolders.some((f) => f.id === folderId);
|
||||
};
|
||||
|
||||
const isFileSelected = (fileId: string): boolean => {
|
||||
return selectedFiles.some((f) => f.id === fileId);
|
||||
};
|
||||
|
||||
const toggleFolderSelection = (folderId: string, folderName: string) => {
|
||||
if (isFolderSelected(folderId)) {
|
||||
onSelectFolders(selectedFolders.filter((f) => f.id !== folderId));
|
||||
|
|
@ -90,6 +98,14 @@ export function GoogleDriveFolderTree({
|
|||
}
|
||||
};
|
||||
|
||||
const toggleFileSelection = (fileId: string, fileName: string) => {
|
||||
if (isFileSelected(fileId)) {
|
||||
onSelectFiles(selectedFiles.filter((f) => f.id !== fileId));
|
||||
} else {
|
||||
onSelectFiles([...selectedFiles, { id: fileId, name: fileName }]);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Find an item by ID across all loaded items (root and nested).
|
||||
*/
|
||||
|
|
@ -200,8 +216,8 @@ export function GoogleDriveFolderTree({
|
|||
const isExpanded = state?.isExpanded || false;
|
||||
const isLoading = state?.isLoading || false;
|
||||
const children = state?.children;
|
||||
const isSelected = isFolderSelected(item.id);
|
||||
const isFolder = item.isFolder;
|
||||
const isSelected = isFolder ? isFolderSelected(item.id) : isFileSelected(item.id);
|
||||
|
||||
const childFolders = children?.filter((c) => c.isFolder) || [];
|
||||
const childFiles = children?.filter((c) => !c.isFolder) || [];
|
||||
|
|
@ -212,6 +228,8 @@ export function GoogleDriveFolderTree({
|
|||
<div key={item.id} className="w-full sm:ml-[calc(var(--level)*1.25rem)]" style={{ marginLeft: `${level * indentSize}rem`, '--level': level } as React.CSSProperties & { '--level'?: number }}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center group gap-2 h-auto py-2 px-2 rounded-md hover:bg-accent cursor-pointer",
|
||||
isSelected && "bg-accent/50"
|
||||
"flex items-center gap-1 sm:gap-2 h-auto py-1 sm:py-2 px-1 sm:px-2 rounded-md",
|
||||
isFolder && "hover:bg-accent cursor-pointer",
|
||||
!isFolder && "cursor-default opacity-60",
|
||||
|
|
@ -240,6 +258,18 @@ export function GoogleDriveFolderTree({
|
|||
<span className="w-3 h-3 sm:w-4 sm:h-4 shrink-0" />
|
||||
)}
|
||||
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => {
|
||||
if (isFolder) {
|
||||
toggleFolderSelection(item.id, item.name);
|
||||
} else {
|
||||
toggleFileSelection(item.id, item.name);
|
||||
}
|
||||
}}
|
||||
className="shrink-0 z-20 group-hover:border-white group-hover:border"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{isFolder && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
|
|
@ -249,7 +279,7 @@ export function GoogleDriveFolderTree({
|
|||
/>
|
||||
)}
|
||||
|
||||
<div className={cn("shrink-0", !isFolder && "ml-3 sm:ml-5")}>
|
||||
<div className="shrink-0">
|
||||
{isFolder ? (
|
||||
isExpanded ? (
|
||||
<FolderOpen className="h-3 w-3 sm:h-4 sm:w-4 text-blue-500" />
|
||||
|
|
|
|||
215
surfsense_web/components/sources/connector-data.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { ConnectorCategory } from "./types";
|
||||
|
||||
export const connectorCategories: ConnectorCategory[] = [
|
||||
{
|
||||
id: "web-crawling",
|
||||
title: "web_crawling",
|
||||
connectors: [
|
||||
{
|
||||
id: "webcrawler-connector",
|
||||
title: "Web Pages",
|
||||
description: "webcrawler_desc",
|
||||
icon: getConnectorIcon(EnumConnectorName.WEBCRAWLER_CONNECTOR, "h-6 w-6"),
|
||||
status: "available",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "web-search",
|
||||
title: "web_search",
|
||||
connectors: [
|
||||
{
|
||||
id: "tavily-api",
|
||||
title: "Tavily API",
|
||||
description: "tavily_desc",
|
||||
icon: getConnectorIcon(EnumConnectorName.TAVILY_API, "h-6 w-6"),
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "searxng",
|
||||
title: "SearxNG",
|
||||
description: "searxng_desc",
|
||||
icon: getConnectorIcon(EnumConnectorName.SEARXNG_API, "h-6 w-6"),
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "linkup-api",
|
||||
title: "Linkup API",
|
||||
description: "linkup_desc",
|
||||
icon: getConnectorIcon(EnumConnectorName.LINKUP_API, "h-6 w-6"),
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "baidu-search-api",
|
||||
title: "Baidu Search",
|
||||
description: "baidu_desc",
|
||||
icon: getConnectorIcon(EnumConnectorName.BAIDU_SEARCH_API, "h-6 w-6"),
|
||||
status: "available",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "messaging",
|
||||
title: "messaging",
|
||||
connectors: [
|
||||
{
|
||||
id: "slack-connector",
|
||||
title: "Slack",
|
||||
description: "slack_desc",
|
||||
icon: getConnectorIcon(EnumConnectorName.SLACK_CONNECTOR, "h-6 w-6"),
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "discord-connector",
|
||||
title: "Discord",
|
||||
description: "discord_desc",
|
||||
icon: getConnectorIcon(EnumConnectorName.DISCORD_CONNECTOR, "h-6 w-6"),
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "ms-teams",
|
||||
title: "Microsoft Teams",
|
||||
description: "teams_desc",
|
||||
icon: getConnectorIcon("ms-teams", "h-6 w-6"),
|
||||
status: "coming-soon",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "project-management",
|
||||
title: "project_management",
|
||||
connectors: [
|
||||
{
|
||||
id: "linear-connector",
|
||||
title: "Linear",
|
||||
description: "linear_desc",
|
||||
icon: getConnectorIcon(EnumConnectorName.LINEAR_CONNECTOR, "h-6 w-6"),
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "jira-connector",
|
||||
title: "Jira",
|
||||
description: "jira_desc",
|
||||
icon: getConnectorIcon(EnumConnectorName.JIRA_CONNECTOR, "h-6 w-6"),
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "clickup-connector",
|
||||
title: "ClickUp",
|
||||
description: "clickup_desc",
|
||||
icon: getConnectorIcon(EnumConnectorName.CLICKUP_CONNECTOR, "h-6 w-6"),
|
||||
status: "available",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "documentation",
|
||||
title: "documentation",
|
||||
connectors: [
|
||||
{
|
||||
id: "notion-connector",
|
||||
title: "Notion",
|
||||
description: "notion_desc",
|
||||
icon: getConnectorIcon(EnumConnectorName.NOTION_CONNECTOR, "h-6 w-6"),
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "confluence-connector",
|
||||
title: "Confluence",
|
||||
description: "confluence_desc",
|
||||
icon: getConnectorIcon(EnumConnectorName.CONFLUENCE_CONNECTOR, "h-6 w-6"),
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "bookstack-connector",
|
||||
title: "BookStack",
|
||||
description: "bookstack_desc",
|
||||
icon: getConnectorIcon(EnumConnectorName.BOOKSTACK_CONNECTOR, "h-6 w-6"),
|
||||
status: "available",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "development",
|
||||
title: "development",
|
||||
connectors: [
|
||||
{
|
||||
id: "github-connector",
|
||||
title: "GitHub",
|
||||
description: "github_desc",
|
||||
icon: getConnectorIcon(EnumConnectorName.GITHUB_CONNECTOR, "h-6 w-6"),
|
||||
status: "available",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "databases",
|
||||
title: "databases",
|
||||
connectors: [
|
||||
{
|
||||
id: "elasticsearch-connector",
|
||||
title: "Elasticsearch",
|
||||
description: "elasticsearch_desc",
|
||||
icon: getConnectorIcon(EnumConnectorName.ELASTICSEARCH_CONNECTOR, "h-6 w-6"),
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "airtable-connector",
|
||||
title: "Airtable",
|
||||
description: "airtable_desc",
|
||||
icon: getConnectorIcon(EnumConnectorName.AIRTABLE_CONNECTOR, "h-6 w-6"),
|
||||
status: "available",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "productivity",
|
||||
title: "productivity",
|
||||
connectors: [
|
||||
{
|
||||
id: "google-calendar-connector",
|
||||
title: "Google Calendar",
|
||||
description: "calendar_desc",
|
||||
icon: getConnectorIcon(EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR, "h-6 w-6"),
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "google-gmail-connector",
|
||||
title: "Gmail",
|
||||
description: "gmail_desc",
|
||||
icon: getConnectorIcon(EnumConnectorName.GOOGLE_GMAIL_CONNECTOR, "h-6 w-6"),
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "google-drive-connector",
|
||||
title: "Google Drive",
|
||||
description: "google_drive_desc",
|
||||
icon: getConnectorIcon(EnumConnectorName.GOOGLE_DRIVE_CONNECTOR, "h-6 w-6"),
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "luma-connector",
|
||||
title: "Luma",
|
||||
description: "luma_desc",
|
||||
icon: getConnectorIcon(EnumConnectorName.LUMA_CONNECTOR, "h-6 w-6"),
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "circleback-connector",
|
||||
title: "Circleback",
|
||||
description: "circleback_desc",
|
||||
icon: getConnectorIcon(EnumConnectorName.CIRCLEBACK_CONNECTOR, "h-6 w-6"),
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "zoom",
|
||||
title: "Zoom",
|
||||
description: "zoom_desc",
|
||||
icon: getConnectorIcon("zoom", "h-6 w-6"),
|
||||
status: "coming-soon",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
@ -1,20 +1,6 @@
|
|||
import {
|
||||
IconBook,
|
||||
IconBooks,
|
||||
IconBrandDiscord,
|
||||
IconBrandElastic,
|
||||
IconBrandGithub,
|
||||
IconBrandNotion,
|
||||
IconBrandSlack,
|
||||
IconBrandYoutube,
|
||||
IconCalendar,
|
||||
IconChecklist,
|
||||
IconLayoutKanban,
|
||||
IconLinkPlus,
|
||||
IconMail,
|
||||
IconSparkles,
|
||||
IconTable,
|
||||
IconTicket,
|
||||
IconUsersGroup,
|
||||
IconWorldWww,
|
||||
} from "@tabler/icons-react";
|
||||
|
|
@ -27,52 +13,53 @@ import {
|
|||
Sparkles,
|
||||
Telescope,
|
||||
Webhook,
|
||||
HardDrive,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { EnumConnectorName } from "./connector";
|
||||
|
||||
export const getConnectorIcon = (connectorType: EnumConnectorName | string, className?: string) => {
|
||||
const iconProps = { className: className || "h-4 w-4" };
|
||||
const imgProps = { className: className || "h-5 w-5", width: 20, height: 20 };
|
||||
|
||||
switch (connectorType) {
|
||||
case EnumConnectorName.LINKUP_API:
|
||||
return <IconLinkPlus {...iconProps} />;
|
||||
case EnumConnectorName.LINEAR_CONNECTOR:
|
||||
return <IconLayoutKanban {...iconProps} />;
|
||||
return <Image src="/connectors/linear.svg" alt="Linear" {...imgProps} />;
|
||||
case EnumConnectorName.GITHUB_CONNECTOR:
|
||||
return <IconBrandGithub {...iconProps} />;
|
||||
return <Image src="/connectors/github.svg" alt="GitHub" {...imgProps} />;
|
||||
case EnumConnectorName.TAVILY_API:
|
||||
return <IconWorldWww {...iconProps} />;
|
||||
case EnumConnectorName.SEARXNG_API:
|
||||
return <Globe {...iconProps} />;
|
||||
case EnumConnectorName.BAIDU_SEARCH_API:
|
||||
return <Search {...iconProps} />;
|
||||
return <Image src="/connectors/baidu-search.svg" alt="Baidu" {...imgProps} />;
|
||||
case EnumConnectorName.SLACK_CONNECTOR:
|
||||
return <IconBrandSlack {...iconProps} />;
|
||||
return <Image src="/connectors/slack.svg" alt="Slack" {...imgProps} />;
|
||||
case EnumConnectorName.NOTION_CONNECTOR:
|
||||
return <IconBrandNotion {...iconProps} />;
|
||||
return <Image src="/connectors/notion.svg" alt="Notion" {...imgProps} />;
|
||||
case EnumConnectorName.DISCORD_CONNECTOR:
|
||||
return <IconBrandDiscord {...iconProps} />;
|
||||
return <Image src="/connectors/discord.svg" alt="Discord" {...imgProps} />;
|
||||
case EnumConnectorName.JIRA_CONNECTOR:
|
||||
return <IconTicket {...iconProps} />;
|
||||
return <Image src="/connectors/jira.svg" alt="Jira" {...imgProps} />;
|
||||
case EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR:
|
||||
return <IconCalendar {...iconProps} />;
|
||||
return <Image src="/connectors/google-calendar.svg" alt="Google Calendar" {...imgProps} />;
|
||||
case EnumConnectorName.GOOGLE_GMAIL_CONNECTOR:
|
||||
return <IconMail {...iconProps} />;
|
||||
return <Image src="/connectors/google-gmail.svg" alt="Gmail" {...imgProps} />;
|
||||
case EnumConnectorName.GOOGLE_DRIVE_CONNECTOR:
|
||||
return <HardDrive {...iconProps} />;
|
||||
return <Image src="/connectors/google-drive.svg" alt="Google Drive" {...imgProps} />;
|
||||
case EnumConnectorName.AIRTABLE_CONNECTOR:
|
||||
return <IconTable {...iconProps} />;
|
||||
return <Image src="/connectors/airtable.svg" alt="Airtable" {...imgProps} />;
|
||||
case EnumConnectorName.CONFLUENCE_CONNECTOR:
|
||||
return <IconBook {...iconProps} />;
|
||||
return <Image src="/connectors/confluence.svg" alt="Confluence" {...imgProps} />;
|
||||
case EnumConnectorName.BOOKSTACK_CONNECTOR:
|
||||
return <IconBooks {...iconProps} />;
|
||||
return <Image src="/connectors/bookstack.svg" alt="BookStack" {...imgProps} />;
|
||||
case EnumConnectorName.CLICKUP_CONNECTOR:
|
||||
return <IconChecklist {...iconProps} />;
|
||||
return <Image src="/connectors/clickup.svg" alt="ClickUp" {...imgProps} />;
|
||||
case EnumConnectorName.LUMA_CONNECTOR:
|
||||
return <IconSparkles {...iconProps} />;
|
||||
case EnumConnectorName.ELASTICSEARCH_CONNECTOR:
|
||||
return <IconBrandElastic {...iconProps} />;
|
||||
return <Image src="/connectors/elasticsearch.svg" alt="Elasticsearch" {...imgProps} />;
|
||||
case EnumConnectorName.WEBCRAWLER_CONNECTOR:
|
||||
return <Globe {...iconProps} />;
|
||||
case EnumConnectorName.CIRCLEBACK_CONNECTOR:
|
||||
|
|
@ -83,7 +70,13 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
|
|||
case "CRAWLED_URL":
|
||||
return <Globe {...iconProps} />;
|
||||
case "YOUTUBE_VIDEO":
|
||||
return <IconBrandYoutube {...iconProps} />;
|
||||
return <Image src="/connectors/youtube.svg" alt="YouTube" {...imgProps} />;
|
||||
case "MICROSOFT_TEAMS":
|
||||
case "ms-teams":
|
||||
return <Image src="/connectors/microsoft-teams.svg" alt="Microsoft Teams" {...imgProps} />;
|
||||
case "ZOOM":
|
||||
case "zoom":
|
||||
return <Image src="/connectors/zoom.svg" alt="Zoom" {...imgProps} />;
|
||||
case "FILE":
|
||||
return <File {...iconProps} />;
|
||||
case "NOTE":
|
||||
|
|
|
|||
|
|
@ -127,6 +127,24 @@ export const deleteConnectorResponse = z.object({
|
|||
message: z.literal("Search source connector deleted successfully"),
|
||||
});
|
||||
|
||||
/**
|
||||
* Google Drive index request body
|
||||
*/
|
||||
export const googleDriveIndexBody = z.object({
|
||||
folders: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
})
|
||||
),
|
||||
files: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Index connector
|
||||
*/
|
||||
|
|
@ -136,10 +154,8 @@ export const indexConnectorRequest = z.object({
|
|||
search_space_id: z.number().or(z.string()),
|
||||
start_date: z.string().optional(),
|
||||
end_date: z.string().optional(),
|
||||
// Google Drive only
|
||||
folder_ids: z.string().optional(),
|
||||
folder_names: z.string().optional(),
|
||||
}),
|
||||
body: googleDriveIndexBody.optional(),
|
||||
});
|
||||
|
||||
export const indexConnectorResponse = z.object({
|
||||
|
|
|
|||
|
|
@ -267,9 +267,7 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?:
|
|||
connectorId: number,
|
||||
searchSpaceId: string | number,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
folderIds?: string,
|
||||
folderNames?: string
|
||||
endDate?: string
|
||||
) => {
|
||||
try {
|
||||
// Build query parameters
|
||||
|
|
@ -282,12 +280,6 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?:
|
|||
if (endDate) {
|
||||
params.append("end_date", endDate);
|
||||
}
|
||||
if (folderIds) {
|
||||
params.append("folder_ids", folderIds);
|
||||
}
|
||||
if (folderNames) {
|
||||
params.append("folder_names", folderNames);
|
||||
}
|
||||
|
||||
const response = await authenticatedFetch(
|
||||
`${
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@ class ConnectorsApiService {
|
|||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
const { connector_id, queryParams } = parsedRequest.data;
|
||||
const { connector_id, queryParams, body } = parsedRequest.data;
|
||||
|
||||
// Transform query params to be string values
|
||||
const transformedQueryParams = Object.fromEntries(
|
||||
|
|
@ -177,7 +177,10 @@ class ConnectorsApiService {
|
|||
|
||||
return baseApiService.post(
|
||||
`/api/v1/search-source-connectors/${connector_id}/index?${queryString}`,
|
||||
indexConnectorResponse
|
||||
indexConnectorResponse,
|
||||
{
|
||||
body: body || {},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
1
surfsense_web/public/connectors/airtable.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><path d="M28.578 5.906L4.717 15.78c-1.327.55-1.313 2.434.022 2.963l23.96 9.502a8.89 8.89 0 0 0 6.555 0l23.96-9.502c1.335-.53 1.35-2.414.022-2.963l-23.86-9.873a8.89 8.89 0 0 0-6.799 0" fill="#fc0"/><path d="M34.103 33.433V57.17a1.6 1.6 0 0 0 2.188 1.486l26.7-10.364A1.6 1.6 0 0 0 64 46.806V23.07a1.6 1.6 0 0 0-2.188-1.486l-26.7 10.364a1.6 1.6 0 0 0-1.009 1.486" fill="#31c2f2"/><path d="M27.87 34.658l-8.728 4.215-16.727 8.015c-1.06.512-2.414-.26-2.414-1.44V23.17c0-.426.218-.794.512-1.07a1.82 1.82 0 0 1 .405-.304c.4-.24.97-.304 1.455-.112l25.365 10.05c1.3.512 1.4 2.318.133 2.925" fill="#ed3049"/><path d="M27.87 34.658l-7.924 3.826L.512 22.098a1.82 1.82 0 0 1 .405-.304c.4-.24.97-.304 1.455-.112l25.365 10.05c1.3.512 1.4 2.318.133 2.925" fill="#c62842"/></svg>
|
||||
|
After Width: | Height: | Size: 825 B |
6
surfsense_web/public/connectors/baidu-search.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Baidu" role="img"
|
||||
viewBox="0 0 512 512"><rect
|
||||
width="512" height="512"
|
||||
rx="15%"
|
||||
fill="#fff"/><path d="m131 251c41-9 35-58 34-68-2-17-21-45-48-43-33 3-37 50-37 50-5 22 10 70 51 61m76-82c22 0 40-26 40-58s-18-58-40-58c-23 0-41 26-41 58s18 58 41 58m96 4c31 4 50-28 54-53 4-24-16-52-37-57s-48 29-50 52c-3 27 3 54 33 58m120 41c0-12-10-47-46-47s-41 33-41 57c0 22 2 53 47 52s40-51 40-62m-46 102s-46-36-74-75c-36-57-89-34-106-5-18 29-45 48-49 53-4 4-56 33-44 84 11 52 52 51 52 51s30 3 65-5 65 2 65 2 81 27 104-25c22-53-13-80-13-80" fill="#2319dc"/><path d="m214 266v34h-28s-29 3-39 35c-3 21 4 34 5 36 1 3 10 19 33 23h53v-128zm-1 107h-21s-15-1-19-18c-3-7 0-16 1-20 1-3 6-11 17-14h22zm38-70v68s1 17 24 23h61v-91h-26v68h-25s-8-1-10-7v-61z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 799 B |
1
surfsense_web/public/connectors/bookstack.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>BookStack</title><path d="M.3013 17.6146c-.1299-.3387-.5228-1.5119-.1337-2.4314l9.8273 5.6738a.329.329 0 0 0 .3299 0L24 12.9616v2.3542l-13.8401 7.9906-9.8586-5.6918zM.1911 8.9628c-.2882.8769.0149 2.0581.1236 2.4261l9.8452 5.6841L24 9.0823V6.7275L10.3248 14.623a.329.329 0 0 1-.3299 0L.1911 8.9628zm13.1698-1.9361c-.1819.1113-.4394.0015-.4852-.2064l-.2805-1.1336-2.1254-.1752a.33.33 0 0 1-.1378-.6145l5.5782-3.2207-1.7021-.9826L.6979 8.4935l9.462 5.463 13.5104-7.8004-4.401-2.5407-5.9084 3.4113zm-.1821-1.7286.2321.938 5.1984-3.0014-2.0395-1.1775-4.994 2.8834 1.3099.108a.3302.3302 0 0 1 .2931.2495zM24 9.845l-13.6752 7.8954a.329.329 0 0 1-.3299 0L.1678 12.0667c-.3891.919.003 2.0914.1332 2.4311l9.8589 5.692L24 12.1993V9.845z"/></svg>
|
||||
|
After Width: | Height: | Size: 812 B |
1
surfsense_web/public/connectors/clickup.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>ClickUp</title><path d="M2 18.439l3.69-2.828c1.961 2.56 4.044 3.739 6.363 3.739 2.307 0 4.33-1.166 6.203-3.704L22 18.405C19.298 22.065 15.941 24 12.053 24 8.178 24 4.788 22.078 2 18.439zM12.04 6.15l-6.568 5.66-3.036-3.52L12.055 0l9.543 8.296-3.05 3.509z"/></svg>
|
||||
|
After Width: | Height: | Size: 340 B |
15
surfsense_web/public/connectors/confluence.svg
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 24C0 10.7452 10.7452 0 24 0C37.2548 0 48 10.7452 48 24C48 37.2548 37.2548 48 24 48C10.7452 48 0 37.2548 0 24Z" fill="white"/>
|
||||
<path d="M12.8682 29.5623C12.6206 29.9661 12.3425 30.4347 12.1063 30.808C11.8949 31.1653 12.0084 31.626 12.3615 31.8442L17.3139 34.8919C17.4878 34.9992 17.6974 35.0322 17.8958 34.9835C18.0943 34.9348 18.2648 34.8084 18.3692 34.6328C18.5673 34.3014 18.8225 33.8709 19.1006 33.4099C21.0625 30.1719 23.0358 30.568 26.5939 32.2671L31.5044 34.6023C31.6904 34.6908 31.9043 34.7003 32.0973 34.6285C32.2904 34.5568 32.4462 34.4099 32.5292 34.2214L34.8873 28.888C35.0539 28.5071 34.8843 28.063 34.5063 27.8899C33.4701 27.4023 31.4092 26.4309 29.5539 25.5357C22.8796 22.2938 17.2073 22.5033 12.8682 29.5623Z" fill="url(#paint0_linear)"/>
|
||||
<path d="M35.0739 17.4595C35.3215 17.0557 35.5996 16.5871 35.8358 16.2138C36.0472 15.8565 35.9338 15.3958 35.5806 15.1776L30.6282 12.13C30.453 12.0119 30.2366 11.972 30.0307 12.0196C29.8248 12.0673 29.648 12.1983 29.5425 12.3814C29.3444 12.7128 29.0892 13.1433 28.8111 13.6043C26.8492 16.8424 24.8758 16.4462 21.3177 14.7471L16.4225 12.4233C16.2365 12.3348 16.0226 12.3253 15.8296 12.3971C15.6365 12.4689 15.4807 12.6158 15.3977 12.8043L13.0396 18.1376C12.873 18.5185 13.0426 18.9627 13.4206 19.1357C14.4568 19.6233 16.5177 20.5947 18.373 21.49C25.0625 24.7281 30.7349 24.5109 35.0739 17.4595Z" fill="url(#paint1_linear)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear" x1="43.3553" y1="32.7159" x2="37.8094" y2="19.9929" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.18" stop-color="#0052CC"/>
|
||||
<stop offset="1" stop-color="#2684FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear" x1="1892.63" y1="-18635.3" x2="1828.06" y2="-18930.7" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.18" stop-color="#0052CC"/>
|
||||
<stop offset="1" stop-color="#2684FF"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
155
surfsense_web/public/connectors/discord.svg
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
<?xml version="1.0" ?><svg style="enable-background:new 0 0 100 100;" version="1.1" viewBox="0 0 100 100" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
|
||||
.st0{fill:#FFFFFF;}
|
||||
.st1{fill:#F5BB41;}
|
||||
.st2{fill:#2167D1;}
|
||||
.st3{fill:#3D84F3;}
|
||||
.st4{fill:#4CA853;}
|
||||
.st5{fill:#398039;}
|
||||
.st6{fill:#D74F3F;}
|
||||
.st7{fill:#D43C89;}
|
||||
.st8{fill:#B2005F;}
|
||||
.st9{fill:none;stroke:#000000;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
|
||||
|
||||
.st10{fill-rule:evenodd;clip-rule:evenodd;fill:none;stroke:#000000;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
|
||||
|
||||
.st11{fill-rule:evenodd;clip-rule:evenodd;fill:none;stroke:#040404;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
|
||||
.st12{fill-rule:evenodd;clip-rule:evenodd;}
|
||||
.st13{fill-rule:evenodd;clip-rule:evenodd;fill:#040404;}
|
||||
.st14{fill:url(#SVGID_1_);}
|
||||
.st15{fill:url(#SVGID_2_);}
|
||||
.st16{fill:url(#SVGID_3_);}
|
||||
.st17{fill:url(#SVGID_4_);}
|
||||
.st18{fill:url(#SVGID_5_);}
|
||||
.st19{fill:url(#SVGID_6_);}
|
||||
.st20{fill:url(#SVGID_7_);}
|
||||
.st21{fill:url(#SVGID_8_);}
|
||||
.st22{fill:url(#SVGID_9_);}
|
||||
.st23{fill:url(#SVGID_10_);}
|
||||
.st24{fill:url(#SVGID_11_);}
|
||||
.st25{fill:url(#SVGID_12_);}
|
||||
.st26{fill:url(#SVGID_13_);}
|
||||
.st27{fill:url(#SVGID_14_);}
|
||||
.st28{fill:url(#SVGID_15_);}
|
||||
.st29{fill:url(#SVGID_16_);}
|
||||
.st30{fill:url(#SVGID_17_);}
|
||||
.st31{fill:url(#SVGID_18_);}
|
||||
.st32{fill:url(#SVGID_19_);}
|
||||
.st33{fill:url(#SVGID_20_);}
|
||||
.st34{fill:url(#SVGID_21_);}
|
||||
.st35{fill:url(#SVGID_22_);}
|
||||
.st36{fill:url(#SVGID_23_);}
|
||||
.st37{fill:url(#SVGID_24_);}
|
||||
.st38{fill:url(#SVGID_25_);}
|
||||
.st39{fill:url(#SVGID_26_);}
|
||||
.st40{fill:url(#SVGID_27_);}
|
||||
.st41{fill:url(#SVGID_28_);}
|
||||
.st42{fill:url(#SVGID_29_);}
|
||||
.st43{fill:url(#SVGID_30_);}
|
||||
.st44{fill:url(#SVGID_31_);}
|
||||
.st45{fill:url(#SVGID_32_);}
|
||||
.st46{fill:url(#SVGID_33_);}
|
||||
.st47{fill:url(#SVGID_34_);}
|
||||
.st48{fill:url(#SVGID_35_);}
|
||||
.st49{fill:url(#SVGID_36_);}
|
||||
.st50{fill:url(#SVGID_37_);}
|
||||
.st51{fill:url(#SVGID_38_);}
|
||||
.st52{fill:url(#SVGID_39_);}
|
||||
.st53{fill:url(#SVGID_40_);}
|
||||
.st54{fill:url(#SVGID_41_);}
|
||||
.st55{fill:url(#SVGID_42_);}
|
||||
.st56{fill:url(#SVGID_43_);}
|
||||
.st57{fill:url(#SVGID_44_);}
|
||||
.st58{fill:url(#SVGID_45_);}
|
||||
.st59{fill:#040404;}
|
||||
.st60{fill:url(#SVGID_46_);}
|
||||
.st61{fill:url(#SVGID_47_);}
|
||||
.st62{fill:url(#SVGID_48_);}
|
||||
.st63{fill:url(#SVGID_49_);}
|
||||
.st64{fill:url(#SVGID_50_);}
|
||||
.st65{fill:url(#SVGID_51_);}
|
||||
.st66{fill:url(#SVGID_52_);}
|
||||
.st67{fill:url(#SVGID_53_);}
|
||||
.st68{fill:url(#SVGID_54_);}
|
||||
.st69{fill:url(#SVGID_55_);}
|
||||
.st70{fill:url(#SVGID_56_);}
|
||||
.st71{fill:url(#SVGID_57_);}
|
||||
.st72{fill:url(#SVGID_58_);}
|
||||
.st73{fill:url(#SVGID_59_);}
|
||||
.st74{fill:url(#SVGID_60_);}
|
||||
.st75{fill:url(#SVGID_61_);}
|
||||
.st76{fill:url(#SVGID_62_);}
|
||||
.st77{fill:none;stroke:#000000;stroke-width:3;stroke-miterlimit:10;}
|
||||
.st78{fill:none;stroke:#FFFFFF;stroke-miterlimit:10;}
|
||||
.st79{fill:#4BC9FF;}
|
||||
.st80{fill:#5500DD;}
|
||||
.st81{fill:#FF3A00;}
|
||||
.st82{fill:#E6162D;}
|
||||
.st83{fill:#F1F1F1;}
|
||||
.st84{fill:#FF9933;}
|
||||
.st85{fill:#B92B27;}
|
||||
.st86{fill:#00ACED;}
|
||||
.st87{fill:#BD2125;}
|
||||
.st88{fill:#1877F2;}
|
||||
.st89{fill:#6665D2;}
|
||||
.st90{fill:#CE3056;}
|
||||
.st91{fill:#5BB381;}
|
||||
.st92{fill:#61C3EC;}
|
||||
.st93{fill:#E4B34B;}
|
||||
.st94{fill:#181EF2;}
|
||||
.st95{fill:#FF0000;}
|
||||
.st96{fill:#FE466C;}
|
||||
.st97{fill:#FA4778;}
|
||||
.st98{fill:#FF7700;}
|
||||
.st99{fill-rule:evenodd;clip-rule:evenodd;fill:#1F6BF6;}
|
||||
.st100{fill:#520094;}
|
||||
.st101{fill:#4477E8;}
|
||||
.st102{fill:#3D1D1C;}
|
||||
.st103{fill:#FFE812;}
|
||||
.st104{fill:#344356;}
|
||||
.st105{fill:#00CC76;}
|
||||
.st106{fill-rule:evenodd;clip-rule:evenodd;fill:#345E90;}
|
||||
.st107{fill:#1F65D8;}
|
||||
.st108{fill:#EB3587;}
|
||||
.st109{fill-rule:evenodd;clip-rule:evenodd;fill:#603A88;}
|
||||
.st110{fill:#E3CE99;}
|
||||
.st111{fill:#783AF9;}
|
||||
.st112{fill:#FF515E;}
|
||||
.st113{fill:#FF4906;}
|
||||
.st114{fill:#503227;}
|
||||
.st115{fill:#4C7BD9;}
|
||||
.st116{fill:#69C9D0;}
|
||||
.st117{fill:#1B92D1;}
|
||||
.st118{fill:#EB4F4A;}
|
||||
.st119{fill:#513728;}
|
||||
.st120{fill:#FF6600;}
|
||||
.st121{fill-rule:evenodd;clip-rule:evenodd;fill:#B61438;}
|
||||
.st122{fill:#FFFC00;}
|
||||
.st123{fill:#141414;}
|
||||
.st124{fill:#94D137;}
|
||||
.st125{fill-rule:evenodd;clip-rule:evenodd;fill:#F1F1F1;}
|
||||
.st126{fill-rule:evenodd;clip-rule:evenodd;fill:#66E066;}
|
||||
.st127{fill:#2D8CFF;}
|
||||
.st128{fill:#F1A300;}
|
||||
.st129{fill:#4BA2F2;}
|
||||
.st130{fill:#1A5099;}
|
||||
.st131{fill:#EE6060;}
|
||||
.st132{fill-rule:evenodd;clip-rule:evenodd;fill:#F48120;}
|
||||
.st133{fill:#222222;}
|
||||
.st134{fill:url(#SVGID_63_);}
|
||||
.st135{fill:#0077B5;}
|
||||
.st136{fill:#FFCC00;}
|
||||
.st137{fill:#EB3352;}
|
||||
.st138{fill:#F9D265;}
|
||||
.st139{fill:#F5B955;}
|
||||
.st140{fill:#DD2A7B;}
|
||||
.st141{fill:#66E066;}
|
||||
.st142{fill:#EB4E00;}
|
||||
.st143{fill:#FFC794;}
|
||||
.st144{fill:#B5332A;}
|
||||
.st145{fill:#4E85EB;}
|
||||
.st146{fill:#58A45C;}
|
||||
.st147{fill:#F2BC42;}
|
||||
.st148{fill:#D85040;}
|
||||
.st149{fill:#464EB8;}
|
||||
.st150{fill:#7B83EB;}
|
||||
</style><g id="Layer_1"/><g id="Layer_2"><g><g><path class="st89" d="M85.22,24.958c-11.459-8.575-22.438-8.334-22.438-8.334l-1.122,1.282 c13.623,4.087,19.954,10.097,19.954,10.097c-19.491-10.731-44.317-10.654-64.59,0c0,0,6.571-6.331,20.996-10.418l-0.801-0.962 c0,0-10.899-0.24-22.438,8.334c0,0-11.54,20.755-11.54,46.319c0,0,6.732,11.54,24.442,12.101c0,0,2.965-3.526,5.369-6.571 c-10.177-3.045-14.024-9.376-14.024-9.376c6.394,4.001,12.859,6.505,20.916,8.094c13.108,2.698,29.413-0.076,41.591-8.094 c0,0-4.007,6.491-14.505,9.456c2.404,2.965,5.289,6.411,5.289,6.411c17.71-0.561,24.441-12.101,24.441-12.02 C96.759,45.713,85.22,24.958,85.22,24.958z M35.055,63.824c-4.488,0-8.174-3.927-8.174-8.815 c0.328-11.707,16.102-11.671,16.348,0C43.229,59.897,39.622,63.824,35.055,63.824z M64.304,63.824 c-4.488,0-8.174-3.927-8.174-8.815c0.36-11.684,15.937-11.689,16.348,0C72.478,59.897,68.872,63.824,64.304,63.824z"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 5.6 KiB |
1
surfsense_web/public/connectors/elasticsearch.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><title>file_type_elastic</title><path d="M12.761,13.89l6.644,3.027,6.7-5.874a7.017,7.017,0,0,0,.141-1.475,7.484,7.484,0,0,0-13.66-4.233L11.466,11.12Z" style="fill:#fed10a"/><path d="M5.886,20.919a7.262,7.262,0,0,0-.141,1.5,7.514,7.514,0,0,0,13.724,4.22l1.1-5.759L19.1,18.059l-6.67-3.04Z" style="fill:#24bbb1"/><path d="M5.848,9.426,10.4,10.5l1-5.169A3.594,3.594,0,0,0,5.848,9.426" style="fill:#ef5098"/><path d="M5.45,10.517a5.016,5.016,0,0,0-.218,9.453L11.62,14.2l-1.167-2.5Z" style="fill:#17a8e0"/><path d="M20.624,26.639a3.589,3.589,0,0,0,5.541-4.092l-4.541-1.065Z" style="fill:#93c83e"/><path d="M21.547,20.29l5,1.167A5.016,5.016,0,0,0,26.768,12l-6.541,5.733Z" style="fill:#0779a1"/></svg>
|
||||
|
After Width: | Height: | Size: 753 B |
1
surfsense_web/public/connectors/github.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="Icons" x="0" y="-192" width="1280" height="800" style="fill:none;"/><g id="Icons1" serif:id="Icons"><g id="Strike"></g><g id="H1"></g><g id="H2"></g><g id="H3"></g><g id="list-ul"></g><g id="hamburger-1"></g><g id="hamburger-2"></g><g id="list-ol"></g><g id="list-task"></g><g id="trash"></g><g id="vertical-menu"></g><g id="horizontal-menu"></g><g id="sidebar-2"></g><g id="Pen"></g><g id="Pen1" serif:id="Pen"></g><g id="clock"></g><g id="external-link"></g><g id="hr"></g><g id="info"></g><g id="warning"></g><g id="plus-circle"></g><g id="minus-circle"></g><g id="vue"></g><g id="cog"></g><path id="github" d="M32.029,8.345c-13.27,0 -24.029,10.759 -24.029,24.033c0,10.617 6.885,19.624 16.435,22.803c1.202,0.22 1.64,-0.522 1.64,-1.16c0,-0.569 -0.02,-2.081 -0.032,-4.086c-6.685,1.452 -8.095,-3.222 -8.095,-3.222c-1.093,-2.775 -2.669,-3.514 -2.669,-3.514c-2.182,-1.492 0.165,-1.462 0.165,-1.462c2.412,0.171 3.681,2.477 3.681,2.477c2.144,3.672 5.625,2.611 6.994,1.997c0.219,-1.553 0.838,-2.612 1.526,-3.213c-5.336,-0.606 -10.947,-2.669 -10.947,-11.877c0,-2.623 0.937,-4.769 2.474,-6.449c-0.247,-0.608 -1.072,-3.051 0.235,-6.36c0,0 2.018,-0.646 6.609,2.464c1.917,-0.533 3.973,-0.8 6.016,-0.809c2.041,0.009 4.097,0.276 6.017,0.809c4.588,-3.11 6.602,-2.464 6.602,-2.464c1.311,3.309 0.486,5.752 0.239,6.36c1.54,1.68 2.471,3.826 2.471,6.449c0,9.232 -5.62,11.263 -10.974,11.858c0.864,0.742 1.632,2.208 1.632,4.451c0,3.212 -0.029,5.804 -0.029,6.591c0,0.644 0.432,1.392 1.652,1.157c9.542,-3.185 16.421,-12.186 16.421,-22.8c0,-13.274 -10.76,-24.033 -24.034,-24.033" style="fill:#010101;"/><g id="logo"></g><g id="eye-slash"></g><g id="eye"></g><g id="toggle-off"></g><g id="shredder"></g><g id="spinner--loading--dots-" serif:id="spinner [loading, dots]"></g><g id="react"></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
44
surfsense_web/public/connectors/google-calendar.svg
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?xml version="1.0" ?><svg id="Capa_1" style="enable-background:new 0 0 150 150;" version="1.1" viewBox="0 0 150 150" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
|
||||
.st0{fill:#1A73E8;}
|
||||
.st1{fill:#EA4335;}
|
||||
.st2{fill:#4285F4;}
|
||||
.st3{fill:#FBBC04;}
|
||||
.st4{fill:#34A853;}
|
||||
.st5{fill:#4CAF50;}
|
||||
.st6{fill:#1E88E5;}
|
||||
.st7{fill:#E53935;}
|
||||
.st8{fill:#C62828;}
|
||||
.st9{fill:#FBC02D;}
|
||||
.st10{fill:#1565C0;}
|
||||
.st11{fill:#2E7D32;}
|
||||
.st12{fill:#F6B704;}
|
||||
.st13{fill:#E54335;}
|
||||
.st14{fill:#4280EF;}
|
||||
.st15{fill:#34A353;}
|
||||
.st16{clip-path:url(#SVGID_2_);}
|
||||
.st17{fill:#188038;}
|
||||
.st18{opacity:0.2;fill:#FFFFFF;enable-background:new ;}
|
||||
.st19{opacity:0.3;fill:#0D652D;enable-background:new ;}
|
||||
.st20{clip-path:url(#SVGID_4_);}
|
||||
.st21{opacity:0.3;fill:url(#_45_shadow_1_);enable-background:new ;}
|
||||
.st22{clip-path:url(#SVGID_6_);}
|
||||
.st23{fill:#FA7B17;}
|
||||
.st24{opacity:0.3;fill:#174EA6;enable-background:new ;}
|
||||
.st25{opacity:0.3;fill:#A50E0E;enable-background:new ;}
|
||||
.st26{opacity:0.3;fill:#E37400;enable-background:new ;}
|
||||
.st27{fill:url(#Finish_mask_1_);}
|
||||
.st28{fill:#FFFFFF;}
|
||||
.st29{fill:#0C9D58;}
|
||||
.st30{opacity:0.2;fill:#004D40;enable-background:new ;}
|
||||
.st31{opacity:0.2;fill:#3E2723;enable-background:new ;}
|
||||
.st32{fill:#FFC107;}
|
||||
.st33{opacity:0.2;fill:#1A237E;enable-background:new ;}
|
||||
.st34{opacity:0.2;}
|
||||
.st35{fill:#1A237E;}
|
||||
.st36{fill:url(#SVGID_7_);}
|
||||
.st37{fill:#FBBC05;}
|
||||
.st38{clip-path:url(#SVGID_9_);fill:#E53935;}
|
||||
.st39{clip-path:url(#SVGID_11_);fill:#FBC02D;}
|
||||
.st40{clip-path:url(#SVGID_13_);fill:#E53935;}
|
||||
.st41{clip-path:url(#SVGID_15_);fill:#FBC02D;}
|
||||
</style><g><polygon class="st6" points="79.2,67.2 81.8,70.9 85.8,68 85.8,89 90.1,89 90.1,61.4 86.5,61.4 "/><path class="st6" d="M72.3,74.4c1.6-1.4,2.6-3.5,2.6-5.7c0-4.4-3.9-8-8.6-8c-4,0-7.5,2.5-8.4,6.2l4.2,1.1c0.4-1.7,2.2-2.9,4.2-2.9 c2.4,0,4.3,1.6,4.3,3.6c0,2-1.9,3.6-4.3,3.6h-2.5v4.4h2.5c2.7,0,5,1.9,5,4.1c0,2.3-2.2,4.1-4.9,4.1c-2.4,0-4.5-1.5-4.8-3.6 l-4.2,0.7c0.7,4.1,4.6,7.2,9.1,7.2c5.1,0,9.2-3.8,9.2-8.5C75.6,78.2,74.3,75.9,72.3,74.4z"/><polygon class="st9" points="100.2,120.3 49.8,120.3 49.8,100.2 100.2,100.2 "/><polygon class="st5" points="120.3,100.2 120.3,49.8 100.2,49.8 100.2,100.2 "/><path class="st6" d="M100.2,49.8V29.7h-63c-4.2,0-7.6,3.4-7.6,7.6v63h20.1V49.8H100.2z"/><polygon class="st7" points="100.2,100.2 100.2,120.3 120.3,100.2 "/><path class="st10" d="M112.8,29.7h-12.6v20.1h20.1V37.2C120.3,33,117,29.7,112.8,29.7z"/><path class="st10" d="M37.2,120.3h12.6v-20.1H29.7v12.6C29.7,117,33,120.3,37.2,120.3z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
44
surfsense_web/public/connectors/google-drive.svg
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?xml version="1.0" ?><svg id="Capa_1" style="enable-background:new 0 0 150 150;" version="1.1" viewBox="0 0 150 150" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
|
||||
.st0{fill:#1A73E8;}
|
||||
.st1{fill:#EA4335;}
|
||||
.st2{fill:#4285F4;}
|
||||
.st3{fill:#FBBC04;}
|
||||
.st4{fill:#34A853;}
|
||||
.st5{fill:#4CAF50;}
|
||||
.st6{fill:#1E88E5;}
|
||||
.st7{fill:#E53935;}
|
||||
.st8{fill:#C62828;}
|
||||
.st9{fill:#FBC02D;}
|
||||
.st10{fill:#1565C0;}
|
||||
.st11{fill:#2E7D32;}
|
||||
.st12{fill:#F6B704;}
|
||||
.st13{fill:#E54335;}
|
||||
.st14{fill:#4280EF;}
|
||||
.st15{fill:#34A353;}
|
||||
.st16{clip-path:url(#SVGID_2_);}
|
||||
.st17{fill:#188038;}
|
||||
.st18{opacity:0.2;fill:#FFFFFF;enable-background:new ;}
|
||||
.st19{opacity:0.3;fill:#0D652D;enable-background:new ;}
|
||||
.st20{clip-path:url(#SVGID_4_);}
|
||||
.st21{opacity:0.3;fill:url(#_45_shadow_1_);enable-background:new ;}
|
||||
.st22{clip-path:url(#SVGID_6_);}
|
||||
.st23{fill:#FA7B17;}
|
||||
.st24{opacity:0.3;fill:#174EA6;enable-background:new ;}
|
||||
.st25{opacity:0.3;fill:#A50E0E;enable-background:new ;}
|
||||
.st26{opacity:0.3;fill:#E37400;enable-background:new ;}
|
||||
.st27{fill:url(#Finish_mask_1_);}
|
||||
.st28{fill:#FFFFFF;}
|
||||
.st29{fill:#0C9D58;}
|
||||
.st30{opacity:0.2;fill:#004D40;enable-background:new ;}
|
||||
.st31{opacity:0.2;fill:#3E2723;enable-background:new ;}
|
||||
.st32{fill:#FFC107;}
|
||||
.st33{opacity:0.2;fill:#1A237E;enable-background:new ;}
|
||||
.st34{opacity:0.2;}
|
||||
.st35{fill:#1A237E;}
|
||||
.st36{fill:url(#SVGID_7_);}
|
||||
.st37{fill:#FBBC05;}
|
||||
.st38{clip-path:url(#SVGID_9_);fill:#E53935;}
|
||||
.st39{clip-path:url(#SVGID_11_);fill:#FBC02D;}
|
||||
.st40{clip-path:url(#SVGID_13_);fill:#E53935;}
|
||||
.st41{clip-path:url(#SVGID_15_);fill:#FBC02D;}
|
||||
</style><g><path class="st6" d="M104.8,113.3c-2,1.2-4.3,1.8-6.7,1.8H51.8c-2.4,0-4.7-0.6-6.7-1.8c7.3-12.6,14.4-25,14.4-25h30.8 C90.4,88.4,99.2,103.6,104.8,113.3z"/><path class="st9" d="M89.4,36.7c2,1.2,3.7,2.8,4.9,4.9l23.2,40.1c1.2,2.1,1.8,4.4,1.8,6.7c-10.5,0.1-28.8,0-28.8,0L75,61.6 C75,61.6,83,47.8,89.4,36.7z"/><path class="st7" d="M119.3,88.4c0,2.3-0.6,4.6-1.8,6.7l-8.2,14.2c-1.2,1.7-2.7,3.1-4.4,4.1l-14.4-25H119.3z"/><path class="st5" d="M30.7,88.4c0-2.3,0.6-4.6,1.8-6.7l23.2-40.1c1.2-2.1,2.9-3.7,4.9-4.9c2-1.2,14.4,25,14.4,25L59.6,88.4 C59.6,88.4,39.4,88.4,30.7,88.4z"/><path class="st10" d="M59.6,88.4l-14.4,25c-1.7-1-3.3-2.4-4.4-4.1l-8.2-14.2c-1.2-2.1-1.8-4.4-1.8-6.7H59.6z"/><path class="st11" d="M89.4,36.7L75,61.6l-14.4-25c1.7-1,3.7-1.6,5.8-1.8l16.3,0C85.1,34.9,87.4,35.5,89.4,36.7z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
44
surfsense_web/public/connectors/google-gmail.svg
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?xml version="1.0" ?><svg id="Capa_1" style="enable-background:new 0 0 150 150;" version="1.1" viewBox="0 0 150 150" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
|
||||
.st0{fill:#1A73E8;}
|
||||
.st1{fill:#EA4335;}
|
||||
.st2{fill:#4285F4;}
|
||||
.st3{fill:#FBBC04;}
|
||||
.st4{fill:#34A853;}
|
||||
.st5{fill:#4CAF50;}
|
||||
.st6{fill:#1E88E5;}
|
||||
.st7{fill:#E53935;}
|
||||
.st8{fill:#C62828;}
|
||||
.st9{fill:#FBC02D;}
|
||||
.st10{fill:#1565C0;}
|
||||
.st11{fill:#2E7D32;}
|
||||
.st12{fill:#F6B704;}
|
||||
.st13{fill:#E54335;}
|
||||
.st14{fill:#4280EF;}
|
||||
.st15{fill:#34A353;}
|
||||
.st16{clip-path:url(#SVGID_2_);}
|
||||
.st17{fill:#188038;}
|
||||
.st18{opacity:0.2;fill:#FFFFFF;enable-background:new ;}
|
||||
.st19{opacity:0.3;fill:#0D652D;enable-background:new ;}
|
||||
.st20{clip-path:url(#SVGID_4_);}
|
||||
.st21{opacity:0.3;fill:url(#_45_shadow_1_);enable-background:new ;}
|
||||
.st22{clip-path:url(#SVGID_6_);}
|
||||
.st23{fill:#FA7B17;}
|
||||
.st24{opacity:0.3;fill:#174EA6;enable-background:new ;}
|
||||
.st25{opacity:0.3;fill:#A50E0E;enable-background:new ;}
|
||||
.st26{opacity:0.3;fill:#E37400;enable-background:new ;}
|
||||
.st27{fill:url(#Finish_mask_1_);}
|
||||
.st28{fill:#FFFFFF;}
|
||||
.st29{fill:#0C9D58;}
|
||||
.st30{opacity:0.2;fill:#004D40;enable-background:new ;}
|
||||
.st31{opacity:0.2;fill:#3E2723;enable-background:new ;}
|
||||
.st32{fill:#FFC107;}
|
||||
.st33{opacity:0.2;fill:#1A237E;enable-background:new ;}
|
||||
.st34{opacity:0.2;}
|
||||
.st35{fill:#1A237E;}
|
||||
.st36{fill:url(#SVGID_7_);}
|
||||
.st37{fill:#FBBC05;}
|
||||
.st38{clip-path:url(#SVGID_9_);fill:#E53935;}
|
||||
.st39{clip-path:url(#SVGID_11_);fill:#FBC02D;}
|
||||
.st40{clip-path:url(#SVGID_13_);fill:#E53935;}
|
||||
.st41{clip-path:url(#SVGID_15_);fill:#FBC02D;}
|
||||
</style><g><path class="st5" d="M121.1,57.9L99.1,74.3v35.8h15.4c3.6,0,6.6-2.9,6.6-6.6V57.9z"/><path class="st6" d="M28.9,57.9l21.9,16.5v35.8H35.5c-3.6,0-6.6-2.9-6.6-6.6V57.9z"/><polygon class="st7" points="99.1,46.9 75,65 50.9,46.9 50.9,74.3 75,92.4 99.1,74.3 "/><path class="st8" d="M28.9,49.3v8.6l21.9,16.5V46.9L44,41.8c-1.6-1.2-3.6-1.9-5.7-1.9l0,0C33.1,39.9,28.9,44.1,28.9,49.3z"/><path class="st9" d="M121.1,49.3v8.6L99.1,74.3V46.9l6.9-5.1c1.6-1.2,3.6-1.9,5.7-1.9l0,0C116.9,39.9,121.1,44.1,121.1,49.3z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
16
surfsense_web/public/connectors/jira.svg
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 24C0 10.7452 10.7452 0 24 0C37.2548 0 48 10.7452 48 24C48 37.2548 37.2548 48 24 48C10.7452 48 0 37.2548 0 24Z" fill="white"/>
|
||||
<path d="M34.9367 12H23.41C23.41 13.38 23.9582 14.7035 24.934 15.6793C25.9098 16.6551 27.2333 17.2033 28.6133 17.2033H30.7367V19.2533C30.7385 22.1245 33.0656 24.4515 35.9367 24.4533V13C35.9367 12.4477 35.489 12 34.9367 12Z" fill="#2684FF"/>
|
||||
<path d="M29.2333 17.7433H17.7067C17.7085 20.6144 20.0355 22.9414 22.9067 22.9433H25.03V25C25.0337 27.8711 27.3622 30.1966 30.2333 30.1966V18.7433C30.2333 18.191 29.7856 17.7433 29.2333 17.7433Z" fill="url(#paint0_linear)"/>
|
||||
<path d="M23.5267 23.4833H12C12 26.357 14.3296 28.6866 17.2033 28.6866H19.3333V30.7366C19.3352 33.6051 21.6582 35.9311 24.5267 35.9366V24.4833C24.5267 23.931 24.079 23.4833 23.5267 23.4833Z" fill="url(#paint1_linear)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear" x1="27.4434" y1="15.326" x2="22.5699" y2="20.4112" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.18" stop-color="#0052CC"/>
|
||||
<stop offset="1" stop-color="#2684FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear" x1="376.829" y1="349.939" x2="167.455" y2="557.146" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.18" stop-color="#0052CC"/>
|
||||
<stop offset="1" stop-color="#2684FF"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
surfsense_web/public/connectors/linear.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" ?><svg fill="none" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M3.03509 12.9431C3.24245 14.9227 4.10472 16.8468 5.62188 18.364C7.13904 19.8811 9.0631 20.7434 11.0428 20.9508L3.03509 12.9431Z" fill="currentColor"/><path d="M3 11.4938L12.4921 20.9858C13.2976 20.9407 14.0981 20.7879 14.8704 20.5273L3.4585 9.11548C3.19793 9.88771 3.0451 10.6883 3 11.4938Z" fill="currentColor"/><path d="M3.86722 8.10999L15.8758 20.1186C16.4988 19.8201 17.0946 19.4458 17.6493 18.9956L4.99021 6.33659C4.54006 6.89125 4.16573 7.487 3.86722 8.10999Z" fill="currentColor"/><path d="M5.66301 5.59517C9.18091 2.12137 14.8488 2.135 18.3498 5.63604C21.8508 9.13708 21.8645 14.8049 18.3907 18.3228L5.66301 5.59517Z" fill="currentColor"/></svg>
|
||||
|
After Width: | Height: | Size: 779 B |
155
surfsense_web/public/connectors/microsoft-teams.svg
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
<?xml version="1.0" ?><svg style="enable-background:new 0 0 100 100;" version="1.1" viewBox="0 0 100 100" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
|
||||
.st0{fill:#FFFFFF;}
|
||||
.st1{fill:#F5BB41;}
|
||||
.st2{fill:#2167D1;}
|
||||
.st3{fill:#3D84F3;}
|
||||
.st4{fill:#4CA853;}
|
||||
.st5{fill:#398039;}
|
||||
.st6{fill:#D74F3F;}
|
||||
.st7{fill:#D43C89;}
|
||||
.st8{fill:#B2005F;}
|
||||
.st9{fill:none;stroke:#000000;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
|
||||
|
||||
.st10{fill-rule:evenodd;clip-rule:evenodd;fill:none;stroke:#000000;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
|
||||
|
||||
.st11{fill-rule:evenodd;clip-rule:evenodd;fill:none;stroke:#040404;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
|
||||
.st12{fill-rule:evenodd;clip-rule:evenodd;}
|
||||
.st13{fill-rule:evenodd;clip-rule:evenodd;fill:#040404;}
|
||||
.st14{fill:url(#SVGID_1_);}
|
||||
.st15{fill:url(#SVGID_2_);}
|
||||
.st16{fill:url(#SVGID_3_);}
|
||||
.st17{fill:url(#SVGID_4_);}
|
||||
.st18{fill:url(#SVGID_5_);}
|
||||
.st19{fill:url(#SVGID_6_);}
|
||||
.st20{fill:url(#SVGID_7_);}
|
||||
.st21{fill:url(#SVGID_8_);}
|
||||
.st22{fill:url(#SVGID_9_);}
|
||||
.st23{fill:url(#SVGID_10_);}
|
||||
.st24{fill:url(#SVGID_11_);}
|
||||
.st25{fill:url(#SVGID_12_);}
|
||||
.st26{fill:url(#SVGID_13_);}
|
||||
.st27{fill:url(#SVGID_14_);}
|
||||
.st28{fill:url(#SVGID_15_);}
|
||||
.st29{fill:url(#SVGID_16_);}
|
||||
.st30{fill:url(#SVGID_17_);}
|
||||
.st31{fill:url(#SVGID_18_);}
|
||||
.st32{fill:url(#SVGID_19_);}
|
||||
.st33{fill:url(#SVGID_20_);}
|
||||
.st34{fill:url(#SVGID_21_);}
|
||||
.st35{fill:url(#SVGID_22_);}
|
||||
.st36{fill:url(#SVGID_23_);}
|
||||
.st37{fill:url(#SVGID_24_);}
|
||||
.st38{fill:url(#SVGID_25_);}
|
||||
.st39{fill:url(#SVGID_26_);}
|
||||
.st40{fill:url(#SVGID_27_);}
|
||||
.st41{fill:url(#SVGID_28_);}
|
||||
.st42{fill:url(#SVGID_29_);}
|
||||
.st43{fill:url(#SVGID_30_);}
|
||||
.st44{fill:url(#SVGID_31_);}
|
||||
.st45{fill:url(#SVGID_32_);}
|
||||
.st46{fill:url(#SVGID_33_);}
|
||||
.st47{fill:url(#SVGID_34_);}
|
||||
.st48{fill:url(#SVGID_35_);}
|
||||
.st49{fill:url(#SVGID_36_);}
|
||||
.st50{fill:url(#SVGID_37_);}
|
||||
.st51{fill:url(#SVGID_38_);}
|
||||
.st52{fill:url(#SVGID_39_);}
|
||||
.st53{fill:url(#SVGID_40_);}
|
||||
.st54{fill:url(#SVGID_41_);}
|
||||
.st55{fill:url(#SVGID_42_);}
|
||||
.st56{fill:url(#SVGID_43_);}
|
||||
.st57{fill:url(#SVGID_44_);}
|
||||
.st58{fill:url(#SVGID_45_);}
|
||||
.st59{fill:#040404;}
|
||||
.st60{fill:url(#SVGID_46_);}
|
||||
.st61{fill:url(#SVGID_47_);}
|
||||
.st62{fill:url(#SVGID_48_);}
|
||||
.st63{fill:url(#SVGID_49_);}
|
||||
.st64{fill:url(#SVGID_50_);}
|
||||
.st65{fill:url(#SVGID_51_);}
|
||||
.st66{fill:url(#SVGID_52_);}
|
||||
.st67{fill:url(#SVGID_53_);}
|
||||
.st68{fill:url(#SVGID_54_);}
|
||||
.st69{fill:url(#SVGID_55_);}
|
||||
.st70{fill:url(#SVGID_56_);}
|
||||
.st71{fill:url(#SVGID_57_);}
|
||||
.st72{fill:url(#SVGID_58_);}
|
||||
.st73{fill:url(#SVGID_59_);}
|
||||
.st74{fill:url(#SVGID_60_);}
|
||||
.st75{fill:url(#SVGID_61_);}
|
||||
.st76{fill:url(#SVGID_62_);}
|
||||
.st77{fill:none;stroke:#000000;stroke-width:3;stroke-miterlimit:10;}
|
||||
.st78{fill:none;stroke:#FFFFFF;stroke-miterlimit:10;}
|
||||
.st79{fill:#4BC9FF;}
|
||||
.st80{fill:#5500DD;}
|
||||
.st81{fill:#FF3A00;}
|
||||
.st82{fill:#E6162D;}
|
||||
.st83{fill:#F1F1F1;}
|
||||
.st84{fill:#FF9933;}
|
||||
.st85{fill:#B92B27;}
|
||||
.st86{fill:#00ACED;}
|
||||
.st87{fill:#BD2125;}
|
||||
.st88{fill:#1877F2;}
|
||||
.st89{fill:#6665D2;}
|
||||
.st90{fill:#CE3056;}
|
||||
.st91{fill:#5BB381;}
|
||||
.st92{fill:#61C3EC;}
|
||||
.st93{fill:#E4B34B;}
|
||||
.st94{fill:#181EF2;}
|
||||
.st95{fill:#FF0000;}
|
||||
.st96{fill:#FE466C;}
|
||||
.st97{fill:#FA4778;}
|
||||
.st98{fill:#FF7700;}
|
||||
.st99{fill-rule:evenodd;clip-rule:evenodd;fill:#1F6BF6;}
|
||||
.st100{fill:#520094;}
|
||||
.st101{fill:#4477E8;}
|
||||
.st102{fill:#3D1D1C;}
|
||||
.st103{fill:#FFE812;}
|
||||
.st104{fill:#344356;}
|
||||
.st105{fill:#00CC76;}
|
||||
.st106{fill-rule:evenodd;clip-rule:evenodd;fill:#345E90;}
|
||||
.st107{fill:#1F65D8;}
|
||||
.st108{fill:#EB3587;}
|
||||
.st109{fill-rule:evenodd;clip-rule:evenodd;fill:#603A88;}
|
||||
.st110{fill:#E3CE99;}
|
||||
.st111{fill:#783AF9;}
|
||||
.st112{fill:#FF515E;}
|
||||
.st113{fill:#FF4906;}
|
||||
.st114{fill:#503227;}
|
||||
.st115{fill:#4C7BD9;}
|
||||
.st116{fill:#69C9D0;}
|
||||
.st117{fill:#1B92D1;}
|
||||
.st118{fill:#EB4F4A;}
|
||||
.st119{fill:#513728;}
|
||||
.st120{fill:#FF6600;}
|
||||
.st121{fill-rule:evenodd;clip-rule:evenodd;fill:#B61438;}
|
||||
.st122{fill:#FFFC00;}
|
||||
.st123{fill:#141414;}
|
||||
.st124{fill:#94D137;}
|
||||
.st125{fill-rule:evenodd;clip-rule:evenodd;fill:#F1F1F1;}
|
||||
.st126{fill-rule:evenodd;clip-rule:evenodd;fill:#66E066;}
|
||||
.st127{fill:#2D8CFF;}
|
||||
.st128{fill:#F1A300;}
|
||||
.st129{fill:#4BA2F2;}
|
||||
.st130{fill:#1A5099;}
|
||||
.st131{fill:#EE6060;}
|
||||
.st132{fill-rule:evenodd;clip-rule:evenodd;fill:#F48120;}
|
||||
.st133{fill:#222222;}
|
||||
.st134{fill:url(#SVGID_63_);}
|
||||
.st135{fill:#0077B5;}
|
||||
.st136{fill:#FFCC00;}
|
||||
.st137{fill:#EB3352;}
|
||||
.st138{fill:#F9D265;}
|
||||
.st139{fill:#F5B955;}
|
||||
.st140{fill:#DD2A7B;}
|
||||
.st141{fill:#66E066;}
|
||||
.st142{fill:#EB4E00;}
|
||||
.st143{fill:#FFC794;}
|
||||
.st144{fill:#B5332A;}
|
||||
.st145{fill:#4E85EB;}
|
||||
.st146{fill:#58A45C;}
|
||||
.st147{fill:#F2BC42;}
|
||||
.st148{fill:#D85040;}
|
||||
.st149{fill:#464EB8;}
|
||||
.st150{fill:#7B83EB;}
|
||||
</style><g id="Layer_1"/><g id="Layer_2"><g><g><path class="st149" d="M84.025,35.881c5.797,0,10.513-4.729,10.513-10.54c-0.577-13.983-20.45-13.979-21.026,0 C73.512,31.152,78.229,35.881,84.025,35.881z"/><path class="st149" d="M90.958,38.71H72.193h-0.036c-0.005,0-0.003,0-0.009,0c-1.123,0-15.805,0-20.538,0v-3.68 c0.784,0.139,1.605,0.232,2.467,0.268c0.093,0.001,0.186-0.006,0.279-0.007c0.358-0.006,0.713-0.023,1.063-0.053 c0.12-0.011,0.239-0.021,0.357-0.035c0.403-0.045,0.801-0.104,1.193-0.181c0.024-0.005,0.05-0.008,0.074-0.012 c1.858-0.379,3.61-1.12,5.167-2.17c1.44-0.971,2.687-2.203,3.693-3.615c0.26-0.341,0.497-0.697,0.718-1.061 c0.021-0.036,0.044-0.07,0.065-0.107c0.17-0.287,0.32-0.584,0.466-0.884c0.064-0.13,0.13-0.26,0.19-0.392 c0.154-0.345,0.296-0.696,0.421-1.053c0.011-0.03,0.022-0.059,0.032-0.088c1.427-4.208,0.774-9.156-1.676-12.856 c-0.648-0.949-1.417-1.806-2.268-2.574c-0.176-0.153-0.344-0.314-0.529-0.457c-0.714-0.588-1.485-1.109-2.304-1.552 c-0.41-0.222-0.831-0.425-1.263-0.607c-0.434-0.192-0.887-0.35-1.347-0.493c-0.264-0.081-0.538-0.141-0.808-0.207 c-0.239-0.058-0.475-0.121-0.717-0.166c-0.2-0.038-0.405-0.062-0.607-0.092c-0.352-0.05-0.704-0.096-1.06-0.121 c-0.122-0.009-0.245-0.012-0.368-0.018C54.486,6.479,54.123,6.48,53.76,6.49c-2.08,0.121-3.926,0.558-5.543,1.24 c-0.33,0.149-0.664,0.294-0.975,0.47C44,9.966,41.52,12.972,40.375,16.493c-0.794,2.629-0.862,5.468-0.187,8.129 c0.007,0.025,0.013,0.051,0.02,0.076c0.032,0.115,0.065,0.23,0.097,0.345c0.039,0.137,0.085,0.273,0.128,0.409 c0.039,0.11,0.08,0.219,0.121,0.329H8.774c-2.846,0-5.162,2.316-5.162,5.162v37.672c0,2.847,2.316,5.162,5.162,5.162h20.122 c0.026,0.118,0.059,0.232,0.087,0.349C31.753,85.025,41.446,92.733,52.9,93.011c9.503-0.231,17.666-5.721,21.753-13.592 c0.061,0.022,0.124,0.038,0.185,0.059c10.182,3.851,21.752-4.229,21.546-15.131V44.122C96.385,41.138,93.95,38.71,90.958,38.71z"/><g><g><path class="st150" d="M77.444,44.232c0.069-2.971-2.287-5.448-5.251-5.521c-0.012,0-21.432,0-21.432,0 c-0.789,0-1.429,0.641-1.429,1.433v29.095c0,1.342-1.089,2.433-2.428,2.433H30.199c-0.429,0-0.836,0.194-1.107,0.527 c-0.271,0.334-0.379,0.772-0.292,1.194c2.367,11.561,12.248,19.837,24.1,20.126c13.856-0.34,24.866-11.914,24.544-25.767 L77.444,44.232z"/></g><path class="st150" d="M54.077,35.298c0.093,0.001,0.186-0.006,0.279-0.007c0.358-0.005,0.713-0.023,1.064-0.053 c0.12-0.011,0.239-0.021,0.357-0.035c0.402-0.045,0.801-0.104,1.193-0.181c0.024-0.005,0.05-0.008,0.074-0.013 c1.858-0.379,3.61-1.12,5.167-2.17c1.441-0.971,2.687-2.203,3.694-3.615c0.26-0.341,0.497-0.697,0.718-1.061 c0.021-0.036,0.044-0.07,0.065-0.107c0.17-0.287,0.32-0.585,0.466-0.884c0.064-0.13,0.13-0.259,0.19-0.392 c0.154-0.345,0.297-0.696,0.421-1.053c0.011-0.03,0.022-0.059,0.032-0.088c1.427-4.208,0.774-9.157-1.676-12.856 c-0.648-0.949-1.417-1.806-2.268-2.574c-0.176-0.153-0.344-0.314-0.529-0.457c-0.714-0.588-1.485-1.109-2.304-1.552 c-0.41-0.222-0.831-0.425-1.263-0.607c-0.434-0.192-0.887-0.35-1.347-0.493c-0.264-0.081-0.538-0.14-0.808-0.207 c-0.239-0.058-0.475-0.121-0.717-0.166c-0.2-0.038-0.404-0.062-0.607-0.092c-0.352-0.05-0.704-0.096-1.06-0.121 c-0.122-0.009-0.245-0.012-0.367-0.018c-0.362-0.016-0.725-0.015-1.088-0.005c-2.08,0.121-3.926,0.557-5.543,1.24 c-0.33,0.149-0.664,0.294-0.975,0.47c-3.242,1.767-5.723,4.773-6.867,8.294c-0.794,2.629-0.862,5.468-0.187,8.129 c0.007,0.025,0.013,0.051,0.02,0.076c0.032,0.115,0.065,0.23,0.097,0.345c0.039,0.137,0.085,0.273,0.128,0.409 c0.06,0.171,0.123,0.34,0.187,0.51h-0.027C42.371,30.941,46.864,34.993,54.077,35.298z"/></g><g><path class="st149" d="M46.448,25.783H8.774c-2.846,0-5.162,2.316-5.162,5.162v37.672c0,2.847,2.316,5.162,5.162,5.162h37.674 c2.846,0,5.161-2.316,5.161-5.162V30.945C51.61,28.099,49.295,25.783,46.448,25.783z"/><path class="st0" d="M37.109,36.271h-19.28c-0.771,0-1.395,0.625-1.395,1.396l0,3.514c0,0.771,0.624,1.396,1.395,1.396h6.22 l0,19.575c0,0.771,0.624,1.396,1.395,1.396h4.134c0.771,0,1.395-0.625,1.395-1.396l0-19.575h6.136 c0.771,0,1.395-0.625,1.395-1.396l0-3.514C38.504,36.896,37.88,36.271,37.109,36.271z"/></g></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 8.8 KiB |
4
surfsense_web/public/connectors/notion.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M24 48C37.2548 48 48 37.2548 48 24C48 10.7452 37.2548 0 24 0C10.7452 0 0 10.7452 0 24C0 37.2548 10.7452 48 24 48Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.4602 9.06842L11.8104 10.2936C10.4679 10.4098 10 11.285 10 12.3342V30.5316C10 31.3492 10.2916 32.048 10.993 32.982L14.9069 38.0563C15.5498 38.8734 16.134 39.0482 17.3616 38.9897L36.6969 37.823C38.3322 37.7069 38.8 36.9481 38.8 35.665V15.1925C38.8 14.5292 38.5374 14.3376 37.7632 13.7728C37.7206 13.7418 37.6764 13.7096 37.6307 13.6761L32.3159 9.94338C31.0308 9.01056 30.5048 8.89318 28.4602 9.06842ZM17.8 14.8567C16.2214 14.9632 15.8627 14.9874 14.9662 14.2596L12.6865 12.4516C12.454 12.2178 12.5706 11.9262 13.1544 11.8683L29.1613 10.7017C30.5045 10.5848 31.2054 11.0522 31.7314 11.4602L34.4769 13.4435C34.5938 13.5016 34.8854 13.8511 34.5347 13.8511L18.0038 14.843L17.8 14.8567ZM15.9587 35.4897V18.1093C15.9587 17.3512 16.1924 17.001 16.893 16.9421L35.8782 15.8343C36.5222 15.776 36.8138 16.1846 36.8138 16.9421V34.206C36.8138 34.9648 36.6966 35.6072 35.6447 35.665L17.4773 36.7155C16.4259 36.7733 15.9587 36.4238 15.9587 35.4897ZM33.8936 19.0416C34.0101 19.5671 33.8936 20.092 33.3668 20.1511L32.4914 20.3254V33.1567C31.7314 33.5649 31.0306 33.7983 30.4466 33.7983C29.5116 33.7983 29.2774 33.5064 28.5771 32.632L22.8513 23.65V32.3404L24.6631 32.7489C24.6631 32.7489 24.6631 33.7983 23.2014 33.7983L19.1716 34.0319C19.0545 33.7983 19.1716 33.2155 19.5803 33.0987L20.6319 32.8075V21.3172L19.1718 21.2003C19.0547 20.6749 19.3463 19.9173 20.1648 19.8585L24.4879 19.5673L30.4466 28.6662V20.617L28.9274 20.4428C28.8107 19.8004 29.2774 19.334 29.8617 19.2761L33.8936 19.0416Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
6
surfsense_web/public/connectors/slack.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
aria-label="Slack" role="img"
|
||||
viewBox="0 0 512 512"><rect
|
||||
width="512" height="512"
|
||||
rx="15%"
|
||||
fill="#fff"/><g fill="#e01e5a"><path id="a" d="M149 305a39 39 0 0 1-78 0c0-22 17-39 39-39h39zM168 305a39 39 0 0 1 78 0v97a39 39 0 0 1-78 0z"/></g><use xlink:href="#a" fill="#36c5f0" transform="rotate(90,256,256)"/><use xlink:href="#a" fill="#2eb67d" transform="rotate(180,256,256)"/><use xlink:href="#a" fill="#ecb22e" transform="rotate(270,256,256)"/></svg>
|
||||
|
After Width: | Height: | Size: 533 B |
1
surfsense_web/public/connectors/youtube.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" ?><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'><svg height="100%" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;" version="1.1" viewBox="0 0 24 24" width="100%" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:serif="http://www.serif.com/" xmlns:xlink="http://www.w3.org/1999/xlink"><rect height="24" id="Artboard15" style="fill:none;" width="24" x="0" y="0"/><g><path d="M2.093,9.075c0.125,-1.941 1.629,-3.509 3.562,-3.716c2.005,-0.202 4.136,-0.311 6.345,-0.311c2.209,0 4.34,0.109 6.345,0.312c1.933,0.206 3.437,1.774 3.562,3.715c0.061,0.956 0.093,1.933 0.093,2.925c0,0.992 -0.032,1.969 -0.093,2.925c-0.125,1.941 -1.629,3.509 -3.562,3.716c-2.005,0.202 -4.136,0.311 -6.345,0.311c-2.209,0 -4.34,-0.109 -6.345,-0.312c-1.933,-0.206 -3.437,-1.774 -3.562,-3.715c-0.061,-0.956 -0.093,-1.933 -0.093,-2.925c0,-0.992 0.032,-1.969 0.093,-2.925Z" style="fill:#f00;"/><path d="M15.055,12l-4.909,2.995l0,-5.99l4.909,2.995Z" style="fill:#fff;"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1 KiB |
4
surfsense_web/public/connectors/zoom.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M24 48C37.2548 48 48 37.2548 48 24C48 10.7452 37.2548 0 24 0C10.7452 0 0 10.7452 0 24C0 37.2548 10.7452 48 24 48Z" fill="#2D8CFF"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.087 15.9353L31.3043 20.884V27.135L38.087 32.0837C38.5669 32.4503 39.1304 32.5636 39.1304 31.5628V16.4562C39.1304 15.5638 38.6782 15.4517 38.087 15.9353ZM8.34782 27.7567V16.4873C8.34782 16.0263 8.72491 15.6522 9.19007 15.6522H25.6254C28.1845 15.6522 30.2609 17.7077 30.2609 20.2433V31.5127C30.2609 31.9737 29.8838 32.3478 29.4186 32.3478H12.9833C10.4242 32.3478 8.34782 30.2923 8.34782 27.7567Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 707 B |