diff --git a/surfsense_backend/alembic/versions/111_add_prompts_table.py b/surfsense_backend/alembic/versions/111_add_prompts_table.py
index 7d4d69fd2..f61c4e298 100644
--- a/surfsense_backend/alembic/versions/111_add_prompts_table.py
+++ b/surfsense_backend/alembic/versions/111_add_prompts_table.py
@@ -42,7 +42,9 @@ def upgrade() -> None:
)
""")
op.execute("CREATE INDEX ix_prompts_user_id ON prompts (user_id)")
- op.execute("CREATE INDEX ix_prompts_search_space_id ON prompts (search_space_id)")
+ op.execute(
+ "CREATE INDEX ix_prompts_search_space_id ON prompts (search_space_id)"
+ )
def downgrade() -> None:
diff --git a/surfsense_backend/app/agents/new_chat/tools/onedrive/create_file.py b/surfsense_backend/app/agents/new_chat/tools/onedrive/create_file.py
index a712c9a45..8dffb18dd 100644
--- a/surfsense_backend/app/agents/new_chat/tools/onedrive/create_file.py
+++ b/surfsense_backend/app/agents/new_chat/tools/onedrive/create_file.py
@@ -81,7 +81,8 @@ def create_create_onedrive_file_tool(
select(SearchSourceConnector).filter(
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
- SearchSourceConnector.connector_type == SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
+ SearchSourceConnector.connector_type
+ == SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
)
)
connectors = result.scalars().all()
@@ -95,12 +96,14 @@ def create_create_onedrive_file_tool(
accounts = []
for c in connectors:
cfg = c.config or {}
- accounts.append({
- "id": c.id,
- "name": c.name,
- "user_email": cfg.get("user_email"),
- "auth_expired": cfg.get("auth_expired", False),
- })
+ accounts.append(
+ {
+ "id": c.id,
+ "name": c.name,
+ "user_email": cfg.get("user_email"),
+ "auth_expired": cfg.get("auth_expired", False),
+ }
+ )
if all(a.get("auth_expired") for a in accounts):
return {
@@ -119,16 +122,22 @@ def create_create_onedrive_file_tool(
client = OneDriveClient(session=db_session, connector_id=cid)
items, err = await client.list_children("root")
if err:
- logger.warning("Failed to list folders for connector %s: %s", cid, err)
+ logger.warning(
+ "Failed to list folders for connector %s: %s", cid, err
+ )
parent_folders[cid] = []
else:
parent_folders[cid] = [
{"folder_id": item["id"], "name": item["name"]}
for item in items
- if item.get("folder") is not None and item.get("id") and item.get("name")
+ if item.get("folder") is not None
+ and item.get("id")
+ and item.get("name")
]
except Exception:
- logger.warning("Error fetching folders for connector %s", cid, exc_info=True)
+ logger.warning(
+ "Error fetching folders for connector %s", cid, exc_info=True
+ )
parent_folders[cid] = []
context: dict[str, Any] = {
@@ -152,8 +161,12 @@ def create_create_onedrive_file_tool(
}
)
- decisions_raw = approval.get("decisions", []) if isinstance(approval, dict) else []
- decisions = decisions_raw if isinstance(decisions_raw, list) else [decisions_raw]
+ decisions_raw = (
+ approval.get("decisions", []) if isinstance(approval, dict) else []
+ )
+ decisions = (
+ decisions_raw if isinstance(decisions_raw, list) else [decisions_raw]
+ )
decisions = [d for d in decisions if isinstance(d, dict)]
if not decisions:
return {"status": "error", "message": "No approval decision received"}
@@ -192,7 +205,8 @@ def create_create_onedrive_file_tool(
SearchSourceConnector.id == final_connector_id,
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
- SearchSourceConnector.connector_type == SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
+ SearchSourceConnector.connector_type
+ == SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
)
)
connector = result.scalars().first()
@@ -200,7 +214,10 @@ def create_create_onedrive_file_tool(
connector = connectors[0]
if not connector:
- return {"status": "error", "message": "Selected OneDrive connector is invalid."}
+ return {
+ "status": "error",
+ "message": "Selected OneDrive connector is invalid.",
+ }
docx_bytes = _markdown_to_docx(final_content or "")
@@ -212,7 +229,9 @@ def create_create_onedrive_file_tool(
mime_type=DOCX_MIME,
)
- logger.info(f"OneDrive file created: id={created.get('id')}, name={created.get('name')}")
+ logger.info(
+ f"OneDrive file created: id={created.get('id')}, name={created.get('name')}"
+ )
kb_message_suffix = ""
try:
diff --git a/surfsense_backend/app/agents/new_chat/tools/onedrive/trash_file.py b/surfsense_backend/app/agents/new_chat/tools/onedrive/trash_file.py
index ae7c5e306..79d8222fd 100644
--- a/surfsense_backend/app/agents/new_chat/tools/onedrive/trash_file.py
+++ b/surfsense_backend/app/agents/new_chat/tools/onedrive/trash_file.py
@@ -52,10 +52,15 @@ def create_delete_onedrive_file_tool(
- If status is "not_found", relay the exact message to the user and ask them
to verify the file name or check if it has been indexed.
"""
- logger.info(f"delete_onedrive_file called: file_name='{file_name}', delete_from_kb={delete_from_kb}")
+ logger.info(
+ f"delete_onedrive_file called: file_name='{file_name}', delete_from_kb={delete_from_kb}"
+ )
if db_session is None or search_space_id is None or user_id is None:
- return {"status": "error", "message": "OneDrive tool not properly configured."}
+ return {
+ "status": "error",
+ "message": "OneDrive tool not properly configured.",
+ }
try:
doc_result = await db_session.execute(
@@ -89,8 +94,12 @@ def create_delete_onedrive_file_tool(
Document.search_space_id == search_space_id,
Document.document_type == DocumentType.ONEDRIVE_FILE,
func.lower(
- cast(Document.document_metadata["onedrive_file_name"], String)
- ) == func.lower(file_name),
+ cast(
+ Document.document_metadata["onedrive_file_name"],
+ String,
+ )
+ )
+ == func.lower(file_name),
SearchSourceConnector.user_id == user_id,
)
)
@@ -110,14 +119,20 @@ def create_delete_onedrive_file_tool(
}
if not document.connector_id:
- return {"status": "error", "message": "Document has no associated connector."}
+ return {
+ "status": "error",
+ "message": "Document has no associated connector.",
+ }
meta = document.document_metadata or {}
file_id = meta.get("onedrive_file_id")
document_id = document.id
if not file_id:
- return {"status": "error", "message": "File ID is missing. Please re-index the file."}
+ return {
+ "status": "error",
+ "message": "File ID is missing. Please re-index the file.",
+ }
conn_result = await db_session.execute(
select(SearchSourceConnector).filter(
@@ -125,13 +140,17 @@ def create_delete_onedrive_file_tool(
SearchSourceConnector.id == document.connector_id,
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
- SearchSourceConnector.connector_type == SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
+ SearchSourceConnector.connector_type
+ == SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
)
)
)
connector = conn_result.scalars().first()
if not connector:
- return {"status": "error", "message": "OneDrive connector not found or access denied."}
+ return {
+ "status": "error",
+ "message": "OneDrive connector not found or access denied.",
+ }
cfg = connector.config or {}
if cfg.get("auth_expired"):
@@ -170,8 +189,12 @@ def create_delete_onedrive_file_tool(
}
)
- decisions_raw = approval.get("decisions", []) if isinstance(approval, dict) else []
- decisions = decisions_raw if isinstance(decisions_raw, list) else [decisions_raw]
+ decisions_raw = (
+ approval.get("decisions", []) if isinstance(approval, dict) else []
+ )
+ decisions = (
+ decisions_raw if isinstance(decisions_raw, list) else [decisions_raw]
+ )
decisions = [d for d in decisions if isinstance(d, dict)]
if not decisions:
return {"status": "error", "message": "No approval decision received"}
@@ -206,7 +229,8 @@ def create_delete_onedrive_file_tool(
SearchSourceConnector.id == final_connector_id,
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
- SearchSourceConnector.connector_type == SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
+ SearchSourceConnector.connector_type
+ == SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
)
)
)
@@ -224,10 +248,14 @@ def create_delete_onedrive_file_tool(
f"Deleting OneDrive file: file_id='{final_file_id}', connector={actual_connector_id}"
)
- client = OneDriveClient(session=db_session, connector_id=actual_connector_id)
+ client = OneDriveClient(
+ session=db_session, connector_id=actual_connector_id
+ )
await client.trash_file(final_file_id)
- logger.info(f"OneDrive file deleted (moved to recycle bin): file_id={final_file_id}")
+ logger.info(
+ f"OneDrive file deleted (moved to recycle bin): file_id={final_file_id}"
+ )
trash_result: dict[str, Any] = {
"status": "success",
@@ -272,6 +300,9 @@ def create_delete_onedrive_file_tool(
if isinstance(e, GraphInterrupt):
raise
logger.error(f"Error deleting OneDrive file: {e}", exc_info=True)
- return {"status": "error", "message": "Something went wrong while trashing the file. Please try again."}
+ return {
+ "status": "error",
+ "message": "Something went wrong while trashing the file. Please try again.",
+ }
return delete_onedrive_file
diff --git a/surfsense_backend/app/connectors/onedrive/client.py b/surfsense_backend/app/connectors/onedrive/client.py
index cc118c0c9..37c5823a3 100644
--- a/surfsense_backend/app/connectors/onedrive/client.py
+++ b/surfsense_backend/app/connectors/onedrive/client.py
@@ -39,7 +39,9 @@ class OneDriveClient:
cfg = connector.config or {}
is_encrypted = cfg.get("_token_encrypted", False)
- token_encryption = TokenEncryption(config.SECRET_KEY) if config.SECRET_KEY else None
+ token_encryption = (
+ TokenEncryption(config.SECRET_KEY) if config.SECRET_KEY else None
+ )
access_token = cfg.get("access_token", "")
refresh_token = cfg.get("refresh_token")
@@ -206,18 +208,20 @@ class OneDriveClient:
async def download_file_to_disk(self, item_id: str, dest_path: str) -> str | None:
"""Stream file content to disk. Returns error message on failure."""
token = await self._get_valid_token()
- async with httpx.AsyncClient(follow_redirects=True) as client:
- async with client.stream(
+ async with (
+ httpx.AsyncClient(follow_redirects=True) as client,
+ client.stream(
"GET",
f"{GRAPH_API_BASE}/me/drive/items/{item_id}/content",
headers={"Authorization": f"Bearer {token}"},
timeout=120.0,
- ) as resp:
- if resp.status_code != 200:
- return f"Download failed: {resp.status_code}"
- with open(dest_path, "wb") as f:
- async for chunk in resp.aiter_bytes(chunk_size=5 * 1024 * 1024):
- f.write(chunk)
+ ) as resp,
+ ):
+ if resp.status_code != 200:
+ return f"Download failed: {resp.status_code}"
+ with open(dest_path, "wb") as f:
+ async for chunk in resp.aiter_bytes(chunk_size=5 * 1024 * 1024):
+ f.write(chunk)
return None
async def create_file(
diff --git a/surfsense_backend/app/connectors/onedrive/content_extractor.py b/surfsense_backend/app/connectors/onedrive/content_extractor.py
index 109a8cb15..8917ba1fd 100644
--- a/surfsense_backend/app/connectors/onedrive/content_extractor.py
+++ b/surfsense_backend/app/connectors/onedrive/content_extractor.py
@@ -5,6 +5,7 @@ extension-based, not provider-specific.
"""
import asyncio
+import contextlib
import logging
import os
import tempfile
@@ -60,7 +61,9 @@ async def download_and_extract_content(
temp_file_path = None
try:
- extension = Path(file_name).suffix or get_extension_from_mime(mime_type) or ".bin"
+ extension = (
+ Path(file_name).suffix or get_extension_from_mime(mime_type) or ".bin"
+ )
with tempfile.NamedTemporaryFile(delete=False, suffix=extension) as tmp:
temp_file_path = tmp.name
@@ -76,10 +79,8 @@ async def download_and_extract_content(
return None, metadata, str(e)
finally:
if temp_file_path and os.path.exists(temp_file_path):
- try:
+ with contextlib.suppress(Exception):
os.unlink(temp_file_path)
- except Exception:
- pass
async def _parse_file_to_markdown(file_path: str, filename: str) -> str:
@@ -94,9 +95,10 @@ async def _parse_file_to_markdown(file_path: str, filename: str) -> str:
return f.read()
if lower.endswith((".mp3", ".mp4", ".mpeg", ".mpga", ".m4a", ".wav", ".webm")):
- from app.config import config as app_config
from litellm import atranscription
+ from app.config import config as app_config
+
stt_service_type = (
"local"
if app_config.STT_SERVICE and app_config.STT_SERVICE.startswith("local/")
@@ -106,9 +108,13 @@ async def _parse_file_to_markdown(file_path: str, filename: str) -> str:
from app.services.stt_service import stt_service
t0 = time.monotonic()
- logger.info(f"[local-stt] START file={filename} thread={threading.current_thread().name}")
+ logger.info(
+ f"[local-stt] START file={filename} thread={threading.current_thread().name}"
+ )
result = await asyncio.to_thread(stt_service.transcribe_file, file_path)
- logger.info(f"[local-stt] END file={filename} elapsed={time.monotonic() - t0:.2f}s")
+ logger.info(
+ f"[local-stt] END file={filename} elapsed={time.monotonic() - t0:.2f}s"
+ )
text = result.get("text", "")
else:
with open(file_path, "rb") as audio_file:
@@ -150,7 +156,9 @@ async def _parse_file_to_markdown(file_path: str, filename: str) -> str:
parse_with_llamacloud_retry,
)
- result = await parse_with_llamacloud_retry(file_path=file_path, estimated_pages=50)
+ result = await parse_with_llamacloud_retry(
+ file_path=file_path, estimated_pages=50
+ )
markdown_documents = await result.aget_markdown_documents(split_by_page=False)
if not markdown_documents:
raise RuntimeError(f"LlamaCloud returned no documents for {filename}")
@@ -161,9 +169,13 @@ async def _parse_file_to_markdown(file_path: str, filename: str) -> str:
converter = DocumentConverter()
t0 = time.monotonic()
- logger.info(f"[docling] START file={filename} thread={threading.current_thread().name}")
+ logger.info(
+ f"[docling] START file={filename} thread={threading.current_thread().name}"
+ )
result = await asyncio.to_thread(converter.convert, file_path)
- logger.info(f"[docling] END file={filename} elapsed={time.monotonic() - t0:.2f}s")
+ logger.info(
+ f"[docling] END file={filename} elapsed={time.monotonic() - t0:.2f}s"
+ )
return result.document.export_to_markdown()
raise RuntimeError(f"Unknown ETL_SERVICE: {app_config.ETL_SERVICE}")
diff --git a/surfsense_backend/app/connectors/onedrive/folder_manager.py b/surfsense_backend/app/connectors/onedrive/folder_manager.py
index 7f286453c..6fa725ca1 100644
--- a/surfsense_backend/app/connectors/onedrive/folder_manager.py
+++ b/surfsense_backend/app/connectors/onedrive/folder_manager.py
@@ -27,7 +27,10 @@ async def list_folder_contents(
if item["isFolder"]:
item.setdefault("mimeType", "application/vnd.ms-folder")
else:
- item.setdefault("mimeType", item.get("file", {}).get("mimeType", "application/octet-stream"))
+ item.setdefault(
+ "mimeType",
+ item.get("file", {}).get("mimeType", "application/octet-stream"),
+ )
items.sort(key=lambda x: (not x["isFolder"], x.get("name", "").lower()))
@@ -63,7 +66,9 @@ async def get_files_in_folder(
client, item["id"], include_subfolders=True
)
if sub_error:
- logger.warning(f"Error recursing into folder {item.get('name')}: {sub_error}")
+ logger.warning(
+ f"Error recursing into folder {item.get('name')}: {sub_error}"
+ )
continue
files.extend(sub_files)
elif not should_skip_file(item):
diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py
index a2b7a154a..644ab07dc 100644
--- a/surfsense_backend/app/routes/__init__.py
+++ b/surfsense_backend/app/routes/__init__.py
@@ -33,9 +33,10 @@ from .new_llm_config_routes import router as new_llm_config_router
from .notes_routes import router as notes_router
from .notifications_routes import router as notifications_router
from .notion_add_connector_route import router as notion_add_connector_router
+from .onedrive_add_connector_route import router as onedrive_add_connector_router
from .podcasts_routes import router as podcasts_router
-from .public_chat_routes import router as public_chat_router
from .prompts_routes import router as prompts_router
+from .public_chat_routes import router as public_chat_router
from .rbac_routes import router as rbac_router
from .reports_routes import router as reports_router
from .sandbox_routes import router as sandbox_router
@@ -44,7 +45,6 @@ from .search_spaces_routes import router as search_spaces_router
from .slack_add_connector_route import router as slack_add_connector_router
from .surfsense_docs_routes import router as surfsense_docs_router
from .teams_add_connector_route import router as teams_add_connector_router
-from .onedrive_add_connector_route import router as onedrive_add_connector_router
from .video_presentations_routes import router as video_presentations_router
from .youtube_routes import router as youtube_router
diff --git a/surfsense_backend/app/routes/onedrive_add_connector_route.py b/surfsense_backend/app/routes/onedrive_add_connector_route.py
index 19bcbe6ff..2f41efca7 100644
--- a/surfsense_backend/app/routes/onedrive_add_connector_route.py
+++ b/surfsense_backend/app/routes/onedrive_add_connector_route.py
@@ -79,9 +79,13 @@ async def connect_onedrive(space_id: int, user: User = Depends(current_active_us
if not space_id:
raise HTTPException(status_code=400, detail="space_id is required")
if not config.MICROSOFT_CLIENT_ID:
- raise HTTPException(status_code=500, detail="Microsoft OneDrive OAuth not configured.")
+ raise HTTPException(
+ status_code=500, detail="Microsoft OneDrive OAuth not configured."
+ )
if not config.SECRET_KEY:
- raise HTTPException(status_code=500, detail="SECRET_KEY not configured for OAuth security.")
+ raise HTTPException(
+ status_code=500, detail="SECRET_KEY not configured for OAuth security."
+ )
state_manager = get_state_manager()
state_encoded = state_manager.generate_secure_state(space_id, user.id)
@@ -96,14 +100,18 @@ async def connect_onedrive(space_id: int, user: User = Depends(current_active_us
}
auth_url = f"{AUTHORIZATION_URL}?{urlencode(auth_params)}"
- logger.info("Generated OneDrive OAuth URL for user %s, space %s", user.id, space_id)
+ logger.info(
+ "Generated OneDrive OAuth URL for user %s, space %s", user.id, space_id
+ )
return {"auth_url": auth_url}
except HTTPException:
raise
except Exception as e:
logger.error("Failed to initiate OneDrive OAuth: %s", str(e), exc_info=True)
- raise HTTPException(status_code=500, detail=f"Failed to initiate OneDrive OAuth: {e!s}") from e
+ raise HTTPException(
+ status_code=500, detail=f"Failed to initiate OneDrive OAuth: {e!s}"
+ ) from e
@router.get("/auth/onedrive/connector/reauth")
@@ -121,15 +129,20 @@ async def reauth_onedrive(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user.id,
SearchSourceConnector.search_space_id == space_id,
- SearchSourceConnector.connector_type == SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
+ SearchSourceConnector.connector_type
+ == SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
)
)
connector = result.scalars().first()
if not connector:
- raise HTTPException(status_code=404, detail="OneDrive connector not found or access denied")
+ raise HTTPException(
+ status_code=404, detail="OneDrive connector not found or access denied"
+ )
if not config.SECRET_KEY:
- raise HTTPException(status_code=500, detail="SECRET_KEY not configured for OAuth security.")
+ raise HTTPException(
+ status_code=500, detail="SECRET_KEY not configured for OAuth security."
+ )
state_manager = get_state_manager()
extra: dict = {"connector_id": connector_id}
@@ -148,14 +161,20 @@ async def reauth_onedrive(
}
auth_url = f"{AUTHORIZATION_URL}?{urlencode(auth_params)}"
- logger.info("Initiating OneDrive re-auth for user %s, connector %s", user.id, connector_id)
+ logger.info(
+ "Initiating OneDrive re-auth for user %s, connector %s",
+ user.id,
+ connector_id,
+ )
return {"auth_url": auth_url}
except HTTPException:
raise
except Exception as e:
logger.error("Failed to initiate OneDrive re-auth: %s", str(e), exc_info=True)
- raise HTTPException(status_code=500, detail=f"Failed to initiate OneDrive re-auth: {e!s}") from e
+ raise HTTPException(
+ status_code=500, detail=f"Failed to initiate OneDrive re-auth: {e!s}"
+ ) from e
@router.get("/auth/onedrive/connector/callback")
@@ -182,10 +201,14 @@ async def onedrive_callback(
return RedirectResponse(
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/connectors/callback?error=onedrive_oauth_denied"
)
- return RedirectResponse(url=f"{config.NEXT_FRONTEND_URL}/dashboard?error=onedrive_oauth_denied")
+ return RedirectResponse(
+ url=f"{config.NEXT_FRONTEND_URL}/dashboard?error=onedrive_oauth_denied"
+ )
if not code or not state:
- raise HTTPException(status_code=400, detail="Missing required OAuth parameters")
+ raise HTTPException(
+ status_code=400, detail="Missing required OAuth parameters"
+ )
state_manager = get_state_manager()
try:
@@ -194,7 +217,9 @@ async def onedrive_callback(
user_id = UUID(data["user_id"])
except (HTTPException, ValueError, KeyError) as e:
logger.error("Invalid OAuth state: %s", str(e))
- return RedirectResponse(url=f"{config.NEXT_FRONTEND_URL}/dashboard?error=invalid_state")
+ return RedirectResponse(
+ url=f"{config.NEXT_FRONTEND_URL}/dashboard?error=invalid_state"
+ )
reauth_connector_id = data.get("connector_id")
reauth_return_url = data.get("return_url")
@@ -222,20 +247,26 @@ async def onedrive_callback(
error_detail = error_json.get("error_description", error_detail)
except Exception:
pass
- raise HTTPException(status_code=400, detail=f"Token exchange failed: {error_detail}")
+ raise HTTPException(
+ status_code=400, detail=f"Token exchange failed: {error_detail}"
+ )
token_json = token_response.json()
access_token = token_json.get("access_token")
refresh_token = token_json.get("refresh_token")
if not access_token:
- raise HTTPException(status_code=400, detail="No access token received from Microsoft")
+ raise HTTPException(
+ status_code=400, detail="No access token received from Microsoft"
+ )
token_encryption = get_token_encryption()
expires_at = None
if token_json.get("expires_in"):
- expires_at = datetime.now(UTC) + timedelta(seconds=int(token_json["expires_in"]))
+ expires_at = datetime.now(UTC) + timedelta(
+ seconds=int(token_json["expires_in"])
+ )
user_info: dict = {}
try:
@@ -248,7 +279,8 @@ async def onedrive_callback(
if user_response.status_code == 200:
user_data = user_response.json()
user_info = {
- "user_email": user_data.get("mail") or user_data.get("userPrincipalName"),
+ "user_email": user_data.get("mail")
+ or user_data.get("userPrincipalName"),
"user_name": user_data.get("displayName"),
}
except Exception as e:
@@ -256,7 +288,9 @@ async def onedrive_callback(
connector_config = {
"access_token": token_encryption.encrypt_token(access_token),
- "refresh_token": token_encryption.encrypt_token(refresh_token) if refresh_token else None,
+ "refresh_token": token_encryption.encrypt_token(refresh_token)
+ if refresh_token
+ else None,
"token_type": token_json.get("token_type", "Bearer"),
"expires_in": token_json.get("expires_in"),
"expires_at": expires_at.isoformat() if expires_at else None,
@@ -273,22 +307,36 @@ async def onedrive_callback(
SearchSourceConnector.id == reauth_connector_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.search_space_id == space_id,
- SearchSourceConnector.connector_type == SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
+ SearchSourceConnector.connector_type
+ == SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
)
)
db_connector = result.scalars().first()
if not db_connector:
- raise HTTPException(status_code=404, detail="Connector not found or access denied during re-auth")
+ raise HTTPException(
+ status_code=404,
+ detail="Connector not found or access denied during re-auth",
+ )
existing_delta_link = db_connector.config.get("delta_link")
- db_connector.config = {**connector_config, "delta_link": existing_delta_link, "auth_expired": False}
+ db_connector.config = {
+ **connector_config,
+ "delta_link": existing_delta_link,
+ "auth_expired": False,
+ }
flag_modified(db_connector, "config")
await session.commit()
await session.refresh(db_connector)
- logger.info("Re-authenticated OneDrive connector %s for user %s", db_connector.id, user_id)
+ logger.info(
+ "Re-authenticated OneDrive connector %s for user %s",
+ db_connector.id,
+ user_id,
+ )
if reauth_return_url and reauth_return_url.startswith("/"):
- return RedirectResponse(url=f"{config.NEXT_FRONTEND_URL}{reauth_return_url}")
+ return RedirectResponse(
+ url=f"{config.NEXT_FRONTEND_URL}{reauth_return_url}"
+ )
return RedirectResponse(
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/connectors/callback?success=true&connector=ONEDRIVE_CONNECTOR&connectorId={db_connector.id}"
)
@@ -298,16 +346,26 @@ async def onedrive_callback(
SearchSourceConnectorType.ONEDRIVE_CONNECTOR, connector_config
)
is_duplicate = await check_duplicate_connector(
- session, SearchSourceConnectorType.ONEDRIVE_CONNECTOR, space_id, user_id, connector_identifier,
+ session,
+ SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
+ space_id,
+ user_id,
+ connector_identifier,
)
if is_duplicate:
- logger.warning("Duplicate OneDrive connector for user %s, space %s", user_id, space_id)
+ logger.warning(
+ "Duplicate OneDrive connector for user %s, space %s", user_id, space_id
+ )
return RedirectResponse(
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/connectors/callback?error=duplicate_account&connector=ONEDRIVE_CONNECTOR"
)
connector_name = await generate_unique_connector_name(
- session, SearchSourceConnectorType.ONEDRIVE_CONNECTOR, space_id, user_id, connector_identifier,
+ session,
+ SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
+ space_id,
+ user_id,
+ connector_identifier,
)
new_connector = SearchSourceConnector(
@@ -323,20 +381,30 @@ async def onedrive_callback(
session.add(new_connector)
await session.commit()
await session.refresh(new_connector)
- logger.info("Successfully created OneDrive connector %s for user %s", new_connector.id, user_id)
+ logger.info(
+ "Successfully created OneDrive connector %s for user %s",
+ new_connector.id,
+ user_id,
+ )
return RedirectResponse(
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/connectors/callback?success=true&connector=ONEDRIVE_CONNECTOR&connectorId={new_connector.id}"
)
except IntegrityError as e:
await session.rollback()
- logger.error("Database integrity error creating OneDrive connector: %s", str(e))
- return RedirectResponse(url=f"{config.NEXT_FRONTEND_URL}/dashboard?error=connector_creation_failed")
+ logger.error(
+ "Database integrity error creating OneDrive connector: %s", str(e)
+ )
+ return RedirectResponse(
+ url=f"{config.NEXT_FRONTEND_URL}/dashboard?error=connector_creation_failed"
+ )
except HTTPException:
raise
except (IntegrityError, ValueError) as e:
logger.error("OneDrive OAuth callback error: %s", str(e), exc_info=True)
- return RedirectResponse(url=f"{config.NEXT_FRONTEND_URL}/dashboard?error=onedrive_auth_error")
+ return RedirectResponse(
+ url=f"{config.NEXT_FRONTEND_URL}/dashboard?error=onedrive_auth_error"
+ )
@router.get("/connectors/{connector_id}/onedrive/folders")
@@ -353,28 +421,44 @@ async def list_onedrive_folders(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user.id,
- SearchSourceConnector.connector_type == SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
+ SearchSourceConnector.connector_type
+ == SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
)
)
connector = result.scalars().first()
if not connector:
- raise HTTPException(status_code=404, detail="OneDrive connector not found or access denied")
+ raise HTTPException(
+ status_code=404, detail="OneDrive connector not found or access denied"
+ )
onedrive_client = OneDriveClient(session, connector_id)
items, error = await list_folder_contents(onedrive_client, parent_id=parent_id)
if error:
error_lower = error.lower()
- if "401" in error or "authentication expired" in error_lower or "invalid_grant" in error_lower:
+ if (
+ "401" in error
+ or "authentication expired" in error_lower
+ or "invalid_grant" in error_lower
+ ):
try:
if connector and not connector.config.get("auth_expired"):
connector.config = {**connector.config, "auth_expired": True}
flag_modified(connector, "config")
await session.commit()
except Exception:
- logger.warning("Failed to persist auth_expired for connector %s", connector_id, exc_info=True)
- raise HTTPException(status_code=400, detail="OneDrive authentication expired. Please re-authenticate.")
- raise HTTPException(status_code=500, detail=f"Failed to list folder contents: {error}")
+ logger.warning(
+ "Failed to persist auth_expired for connector %s",
+ connector_id,
+ exc_info=True,
+ )
+ raise HTTPException(
+ status_code=400,
+ detail="OneDrive authentication expired. Please re-authenticate.",
+ )
+ raise HTTPException(
+ status_code=500, detail=f"Failed to list folder contents: {error}"
+ )
return {"items": items}
@@ -391,8 +475,13 @@ async def list_onedrive_folders(
await session.commit()
except Exception:
pass
- raise HTTPException(status_code=400, detail="OneDrive authentication expired. Please re-authenticate.") from e
- raise HTTPException(status_code=500, detail=f"Failed to list OneDrive contents: {e!s}") from e
+ raise HTTPException(
+ status_code=400,
+ detail="OneDrive authentication expired. Please re-authenticate.",
+ ) from e
+ raise HTTPException(
+ status_code=500, detail=f"Failed to list OneDrive contents: {e!s}"
+ ) from e
async def refresh_onedrive_token(
@@ -410,10 +499,15 @@ async def refresh_onedrive_token(
refresh_token = token_encryption.decrypt_token(refresh_token)
except Exception as e:
logger.error("Failed to decrypt refresh token: %s", str(e))
- raise HTTPException(status_code=500, detail="Failed to decrypt stored refresh token") from e
+ raise HTTPException(
+ status_code=500, detail="Failed to decrypt stored refresh token"
+ ) from e
if not refresh_token:
- raise HTTPException(status_code=400, detail=f"No refresh token available for connector {connector.id}")
+ raise HTTPException(
+ status_code=400,
+ detail=f"No refresh token available for connector {connector.id}",
+ )
refresh_data = {
"client_id": config.MICROSOFT_CLIENT_ID,
@@ -425,8 +519,10 @@ async def refresh_onedrive_token(
async with httpx.AsyncClient() as client:
token_response = await client.post(
- TOKEN_URL, data=refresh_data,
- headers={"Content-Type": "application/x-www-form-urlencoded"}, timeout=30.0,
+ TOKEN_URL,
+ data=refresh_data,
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
+ timeout=30.0,
)
if token_response.status_code != 200:
@@ -439,16 +535,27 @@ async def refresh_onedrive_token(
except Exception:
pass
error_lower = (error_detail + error_code).lower()
- if "invalid_grant" in error_lower or "expired" in error_lower or "revoked" in error_lower:
- raise HTTPException(status_code=401, detail="OneDrive authentication failed. Please re-authenticate.")
- raise HTTPException(status_code=400, detail=f"Token refresh failed: {error_detail}")
+ if (
+ "invalid_grant" in error_lower
+ or "expired" in error_lower
+ or "revoked" in error_lower
+ ):
+ raise HTTPException(
+ status_code=401,
+ detail="OneDrive authentication failed. Please re-authenticate.",
+ )
+ raise HTTPException(
+ status_code=400, detail=f"Token refresh failed: {error_detail}"
+ )
token_json = token_response.json()
access_token = token_json.get("access_token")
new_refresh_token = token_json.get("refresh_token")
if not access_token:
- raise HTTPException(status_code=400, detail="No access token received from Microsoft refresh")
+ raise HTTPException(
+ status_code=400, detail="No access token received from Microsoft refresh"
+ )
expires_at = None
expires_in = token_json.get("expires_in")
diff --git a/surfsense_backend/app/routes/search_source_connectors_routes.py b/surfsense_backend/app/routes/search_source_connectors_routes.py
index 7e9ac1e59..d12fa3745 100644
--- a/surfsense_backend/app/routes/search_source_connectors_routes.py
+++ b/surfsense_backend/app/routes/search_source_connectors_routes.py
@@ -2567,8 +2567,12 @@ async def run_onedrive_indexing(
search_space_id=search_space_id,
folder_count=len(items_dict.get("folders", [])),
file_count=len(items_dict.get("files", [])),
- folder_names=[f.get("name", "Unknown") for f in items_dict.get("folders", [])],
- file_names=[f.get("name", "Unknown") for f in items_dict.get("files", [])],
+ folder_names=[
+ f.get("name", "Unknown") for f in items_dict.get("folders", [])
+ ],
+ file_names=[
+ f.get("name", "Unknown") for f in items_dict.get("files", [])
+ ],
)
if notification:
@@ -2593,7 +2597,9 @@ async def run_onedrive_indexing(
)
if _is_auth_error(error_message):
await _persist_auth_expired(session, connector_id)
- error_message = "OneDrive authentication expired. Please re-authenticate."
+ error_message = (
+ "OneDrive authentication expired. Please re-authenticate."
+ )
else:
if notification:
await session.refresh(notification)
diff --git a/surfsense_backend/app/services/onedrive/kb_sync_service.py b/surfsense_backend/app/services/onedrive/kb_sync_service.py
index 5e82950a5..962c19fc9 100644
--- a/surfsense_backend/app/services/onedrive/kb_sync_service.py
+++ b/surfsense_backend/app/services/onedrive/kb_sync_service.py
@@ -56,9 +56,7 @@ class OneDriveKBSyncService:
indexable_content = (content or "").strip()
if not indexable_content:
- indexable_content = (
- f"OneDrive file: {file_name} (type: {mime_type})"
- )
+ indexable_content = f"OneDrive file: {file_name} (type: {mime_type})"
content_hash = generate_content_hash(indexable_content, search_space_id)
@@ -95,9 +93,7 @@ class OneDriveKBSyncService:
)
else:
logger.warning("No LLM configured — using fallback summary")
- summary_content = (
- f"OneDrive File: {file_name}\n\n{indexable_content}"
- )
+ summary_content = f"OneDrive File: {file_name}\n\n{indexable_content}"
summary_embedding = embed_text(summary_content)
chunks = await create_document_chunks(indexable_content)
diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py
index 9015f7e51..4b37cb69e 100644
--- a/surfsense_backend/app/tasks/chat/stream_new_chat.py
+++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py
@@ -1076,7 +1076,11 @@ async def _stream_agent_events(
},
)
elif tool_name == "web_search":
- xml = tool_output.get("result", str(tool_output)) if isinstance(tool_output, dict) else str(tool_output)
+ xml = (
+ tool_output.get("result", str(tool_output))
+ if isinstance(tool_output, dict)
+ else str(tool_output)
+ )
citations: dict[str, dict[str, str]] = {}
for m in re.finditer(
r"
\s* ",
diff --git a/surfsense_backend/app/tasks/connector_indexers/onedrive_indexer.py b/surfsense_backend/app/tasks/connector_indexers/onedrive_indexer.py
index e565f6a6a..748cb0988 100644
--- a/surfsense_backend/app/tasks/connector_indexers/onedrive_indexer.py
+++ b/surfsense_backend/app/tasks/connector_indexers/onedrive_indexer.py
@@ -45,6 +45,7 @@ logger = logging.getLogger(__name__)
# Helpers
# ---------------------------------------------------------------------------
+
async def _should_skip_file(
session: AsyncSession,
file: dict,
@@ -186,9 +187,13 @@ async def _download_files_parallel(
logger.warning(f"Download/ETL failed for {file_name}: {reason}")
return None
doc = _build_connector_doc(
- file, markdown, od_metadata,
- connector_id=connector_id, search_space_id=search_space_id,
- user_id=user_id, enable_summary=enable_summary,
+ file,
+ markdown,
+ od_metadata,
+ connector_id=connector_id,
+ search_space_id=search_space_id,
+ user_id=user_id,
+ enable_summary=enable_summary,
)
async with hb_lock:
completed_count += 1
@@ -204,9 +209,7 @@ async def _download_files_parallel(
failed = 0
for outcome in outcomes:
- if isinstance(outcome, Exception):
- failed += 1
- elif outcome is None:
+ if isinstance(outcome, Exception) or outcome is None:
failed += 1
else:
results.append(outcome)
@@ -227,9 +230,12 @@ async def _download_and_index(
) -> tuple[int, int]:
"""Parallel download then parallel indexing. Returns (batch_indexed, total_failed)."""
connector_docs, download_failed = await _download_files_parallel(
- onedrive_client, files,
- connector_id=connector_id, search_space_id=search_space_id,
- user_id=user_id, enable_summary=enable_summary,
+ onedrive_client,
+ files,
+ connector_id=connector_id,
+ search_space_id=search_space_id,
+ user_id=user_id,
+ enable_summary=enable_summary,
on_heartbeat=on_heartbeat,
)
@@ -242,7 +248,9 @@ async def _download_and_index(
return await get_user_long_context_llm(s, user_id, search_space_id)
_, batch_indexed, batch_failed = await pipeline.index_batch_parallel(
- connector_docs, _get_llm, max_concurrency=3,
+ connector_docs,
+ _get_llm,
+ max_concurrency=3,
on_heartbeat=on_heartbeat,
)
@@ -305,10 +313,14 @@ async def _index_selected_files(
files_to_download.append(file)
- batch_indexed, failed = await _download_and_index(
- onedrive_client, session, files_to_download,
- connector_id=connector_id, search_space_id=search_space_id,
- user_id=user_id, enable_summary=enable_summary,
+ batch_indexed, _failed = await _download_and_index(
+ onedrive_client,
+ session,
+ files_to_download,
+ connector_id=connector_id,
+ search_space_id=search_space_id,
+ user_id=user_id,
+ enable_summary=enable_summary,
on_heartbeat=on_heartbeat,
)
@@ -319,6 +331,7 @@ async def _index_selected_files(
# Scan strategies
# ---------------------------------------------------------------------------
+
async def _index_full_scan(
onedrive_client: OneDriveClient,
session: AsyncSession,
@@ -338,7 +351,11 @@ async def _index_full_scan(
await task_logger.log_task_progress(
log_entry,
f"Starting full scan of folder: {folder_name}",
- {"stage": "full_scan", "folder_id": folder_id, "include_subfolders": include_subfolders},
+ {
+ "stage": "full_scan",
+ "folder_id": folder_id,
+ "include_subfolders": include_subfolders,
+ },
)
renamed_count = 0
@@ -346,12 +363,16 @@ async def _index_full_scan(
files_to_download: list[dict] = []
all_files, error = await get_files_in_folder(
- onedrive_client, folder_id, include_subfolders=include_subfolders,
+ onedrive_client,
+ folder_id,
+ include_subfolders=include_subfolders,
)
if error:
err_lower = error.lower()
if "401" in error or "authentication expired" in err_lower:
- raise Exception(f"OneDrive authentication failed. Please re-authenticate. (Error: {error})")
+ raise Exception(
+ f"OneDrive authentication failed. Please re-authenticate. (Error: {error})"
+ )
raise Exception(f"Failed to list OneDrive files: {error}")
for file in all_files[:max_files]:
@@ -365,14 +386,20 @@ async def _index_full_scan(
files_to_download.append(file)
batch_indexed, failed = await _download_and_index(
- onedrive_client, session, files_to_download,
- connector_id=connector_id, search_space_id=search_space_id,
- user_id=user_id, enable_summary=enable_summary,
+ onedrive_client,
+ session,
+ files_to_download,
+ connector_id=connector_id,
+ search_space_id=search_space_id,
+ user_id=user_id,
+ enable_summary=enable_summary,
on_heartbeat=on_heartbeat_callback,
)
indexed = renamed_count + batch_indexed
- logger.info(f"Full scan complete: {indexed} indexed, {skipped} skipped, {failed} failed")
+ logger.info(
+ f"Full scan complete: {indexed} indexed, {skipped} skipped, {failed} failed"
+ )
return indexed, skipped
@@ -392,7 +419,8 @@ async def _index_with_delta_sync(
) -> tuple[int, int, str | None]:
"""Delta sync using OneDrive change tracking. Returns (indexed, skipped, new_delta_link)."""
await task_logger.log_task_progress(
- log_entry, "Starting delta sync",
+ log_entry,
+ "Starting delta sync",
{"stage": "delta_sync"},
)
@@ -402,7 +430,9 @@ async def _index_with_delta_sync(
if error:
err_lower = error.lower()
if "401" in error or "authentication expired" in err_lower:
- raise Exception(f"OneDrive authentication failed. Please re-authenticate. (Error: {error})")
+ raise Exception(
+ f"OneDrive authentication failed. Please re-authenticate. (Error: {error})"
+ )
raise Exception(f"Failed to fetch OneDrive changes: {error}")
if not changes:
@@ -444,14 +474,20 @@ async def _index_with_delta_sync(
files_to_download.append(change)
batch_indexed, failed = await _download_and_index(
- onedrive_client, session, files_to_download,
- connector_id=connector_id, search_space_id=search_space_id,
- user_id=user_id, enable_summary=enable_summary,
+ onedrive_client,
+ session,
+ files_to_download,
+ connector_id=connector_id,
+ search_space_id=search_space_id,
+ user_id=user_id,
+ enable_summary=enable_summary,
on_heartbeat=on_heartbeat_callback,
)
indexed = renamed_count + batch_indexed
- logger.info(f"Delta sync complete: {indexed} indexed, {skipped} skipped, {failed} failed")
+ logger.info(
+ f"Delta sync complete: {indexed} indexed, {skipped} skipped, {failed} failed"
+ )
return indexed, skipped, new_delta_link
@@ -459,6 +495,7 @@ async def _index_with_delta_sync(
# Public entry point
# ---------------------------------------------------------------------------
+
async def index_onedrive_files(
session: AsyncSession,
connector_id: int,
@@ -489,13 +526,20 @@ async def index_onedrive_files(
)
if not connector:
error_msg = f"OneDrive connector with ID {connector_id} not found"
- await task_logger.log_task_failure(log_entry, error_msg, None, {"error_type": "ConnectorNotFound"})
+ await task_logger.log_task_failure(
+ log_entry, error_msg, None, {"error_type": "ConnectorNotFound"}
+ )
return 0, 0, error_msg
token_encrypted = connector.config.get("_token_encrypted", False)
if token_encrypted and not config.SECRET_KEY:
error_msg = "SECRET_KEY not configured but credentials are encrypted"
- await task_logger.log_task_failure(log_entry, error_msg, "Missing SECRET_KEY", {"error_type": "MissingSecretKey"})
+ await task_logger.log_task_failure(
+ log_entry,
+ error_msg,
+ "Missing SECRET_KEY",
+ {"error_type": "MissingSecretKey"},
+ )
return 0, 0, error_msg
connector_enable_summary = getattr(connector, "enable_summary", True)
@@ -513,10 +557,14 @@ async def index_onedrive_files(
selected_files = items_dict.get("files", [])
if selected_files:
file_tuples = [(f["id"], f.get("name")) for f in selected_files]
- indexed, skipped, errors = await _index_selected_files(
- onedrive_client, session, file_tuples,
- connector_id=connector_id, search_space_id=search_space_id,
- user_id=user_id, enable_summary=connector_enable_summary,
+ indexed, skipped, _errors = await _index_selected_files(
+ onedrive_client,
+ session,
+ file_tuples,
+ connector_id=connector_id,
+ search_space_id=search_space_id,
+ user_id=user_id,
+ enable_summary=connector_enable_summary,
)
total_indexed += indexed
total_skipped += skipped
@@ -534,8 +582,16 @@ async def index_onedrive_files(
if can_use_delta:
logger.info(f"Using delta sync for folder {folder_name}")
indexed, skipped, new_delta_link = await _index_with_delta_sync(
- onedrive_client, session, connector_id, search_space_id, user_id,
- folder_id, delta_link, task_logger, log_entry, max_files,
+ onedrive_client,
+ session,
+ connector_id,
+ search_space_id,
+ user_id,
+ folder_id,
+ delta_link,
+ task_logger,
+ log_entry,
+ max_files,
enable_summary=connector_enable_summary,
)
total_indexed += indexed
@@ -550,18 +606,36 @@ async def index_onedrive_files(
# Reconciliation full scan
ri, rs = await _index_full_scan(
- onedrive_client, session, connector_id, search_space_id, user_id,
- folder_id, folder_name, task_logger, log_entry, max_files,
- include_subfolders, enable_summary=connector_enable_summary,
+ onedrive_client,
+ session,
+ connector_id,
+ search_space_id,
+ user_id,
+ folder_id,
+ folder_name,
+ task_logger,
+ log_entry,
+ max_files,
+ include_subfolders,
+ enable_summary=connector_enable_summary,
)
total_indexed += ri
total_skipped += rs
else:
logger.info(f"Using full scan for folder {folder_name}")
indexed, skipped = await _index_full_scan(
- onedrive_client, session, connector_id, search_space_id, user_id,
- folder_id, folder_name, task_logger, log_entry, max_files,
- include_subfolders, enable_summary=connector_enable_summary,
+ onedrive_client,
+ session,
+ connector_id,
+ search_space_id,
+ user_id,
+ folder_id,
+ folder_name,
+ task_logger,
+ log_entry,
+ max_files,
+ include_subfolders,
+ enable_summary=connector_enable_summary,
)
total_indexed += indexed
total_skipped += skipped
@@ -585,22 +659,28 @@ async def index_onedrive_files(
f"Successfully completed OneDrive indexing for connector {connector_id}",
{"files_processed": total_indexed, "files_skipped": total_skipped},
)
- logger.info(f"OneDrive indexing completed: {total_indexed} indexed, {total_skipped} skipped")
+ logger.info(
+ f"OneDrive indexing completed: {total_indexed} indexed, {total_skipped} skipped"
+ )
return total_indexed, total_skipped, None
except SQLAlchemyError as db_error:
await session.rollback()
await task_logger.log_task_failure(
- log_entry, f"Database error during OneDrive indexing for connector {connector_id}",
- str(db_error), {"error_type": "SQLAlchemyError"},
+ log_entry,
+ f"Database error during OneDrive indexing for connector {connector_id}",
+ str(db_error),
+ {"error_type": "SQLAlchemyError"},
)
logger.error(f"Database error: {db_error!s}", exc_info=True)
return 0, 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 OneDrive files for connector {connector_id}",
- str(e), {"error_type": type(e).__name__},
+ log_entry,
+ f"Failed to index OneDrive files for connector {connector_id}",
+ str(e),
+ {"error_type": type(e).__name__},
)
logger.error(f"Failed to index OneDrive files: {e!s}", exc_info=True)
return 0, 0, f"Failed to index OneDrive files: {e!s}"
diff --git a/surfsense_backend/tests/integration/indexing_pipeline/test_onedrive_pipeline.py b/surfsense_backend/tests/integration/indexing_pipeline/test_onedrive_pipeline.py
index ee83795a5..541e3a38e 100644
--- a/surfsense_backend/tests/integration/indexing_pipeline/test_onedrive_pipeline.py
+++ b/surfsense_backend/tests/integration/indexing_pipeline/test_onedrive_pipeline.py
@@ -13,7 +13,9 @@ _EMBEDDING_DIM = app_config.embedding_model_instance.dimension
pytestmark = pytest.mark.integration
-def _onedrive_doc(*, unique_id: str, search_space_id: int, connector_id: int, user_id: str) -> ConnectorDocument:
+def _onedrive_doc(
+ *, unique_id: str, search_space_id: int, connector_id: int, user_id: str
+) -> ConnectorDocument:
return ConnectorDocument(
title=f"File {unique_id}.docx",
source_markdown=f"## Document\n\nContent from {unique_id}",
@@ -32,7 +34,9 @@ def _onedrive_doc(*, unique_id: str, search_space_id: int, connector_id: int, us
)
-@pytest.mark.usefixtures("patched_summarize", "patched_embed_texts", "patched_chunk_text")
+@pytest.mark.usefixtures(
+ "patched_summarize", "patched_embed_texts", "patched_chunk_text"
+)
async def test_onedrive_pipeline_creates_ready_document(
db_session, db_search_space, db_connector, db_user, mocker
):
@@ -61,7 +65,9 @@ async def test_onedrive_pipeline_creates_ready_document(
assert DocumentStatus.is_state(row.status, DocumentStatus.READY)
-@pytest.mark.usefixtures("patched_summarize", "patched_embed_texts", "patched_chunk_text")
+@pytest.mark.usefixtures(
+ "patched_summarize", "patched_embed_texts", "patched_chunk_text"
+)
async def test_onedrive_duplicate_content_skipped(
db_session, db_search_space, db_connector, db_user, mocker
):
@@ -87,8 +93,6 @@ async def test_onedrive_duplicate_content_skipped(
)
first_doc = result.scalars().first()
assert first_doc is not None
- first_id = first_doc.id
-
doc2 = _onedrive_doc(
unique_id="od-dup-file",
search_space_id=space_id,
@@ -97,4 +101,6 @@ async def test_onedrive_duplicate_content_skipped(
)
prepared2 = await service.prepare_for_indexing([doc2])
- assert len(prepared2) == 0 or (len(prepared2) == 1 and prepared2[0].existing_document is not None)
+ assert len(prepared2) == 0 or (
+ len(prepared2) == 1 and prepared2[0].existing_document is not None
+ )
diff --git a/surfsense_backend/tests/unit/connector_indexers/test_onedrive_parallel.py b/surfsense_backend/tests/unit/connector_indexers/test_onedrive_parallel.py
index b5c774c6f..12a912b03 100644
--- a/surfsense_backend/tests/unit/connector_indexers/test_onedrive_parallel.py
+++ b/surfsense_backend/tests/unit/connector_indexers/test_onedrive_parallel.py
@@ -48,12 +48,14 @@ def patch_extract(monkeypatch):
mock,
)
return mock
+
return _patch
# Slice 1: Tracer bullet
async def test_single_file_returns_one_connector_document(
- mock_onedrive_client, patch_extract,
+ mock_onedrive_client,
+ patch_extract,
):
patch_extract(return_value=_mock_extract_ok("f1", "test.txt"))
@@ -75,7 +77,8 @@ async def test_single_file_returns_one_connector_document(
# Slice 2: Multiple files all produce documents
async def test_multiple_files_all_produce_documents(
- mock_onedrive_client, patch_extract,
+ mock_onedrive_client,
+ patch_extract,
):
files = [_make_file_dict(f"f{i}", f"file{i}.txt") for i in range(3)]
patch_extract(
@@ -98,7 +101,8 @@ async def test_multiple_files_all_produce_documents(
# Slice 3: Error isolation
async def test_one_download_exception_does_not_block_others(
- mock_onedrive_client, patch_extract,
+ mock_onedrive_client,
+ patch_extract,
):
files = [_make_file_dict(f"f{i}", f"file{i}.txt") for i in range(3)]
patch_extract(
@@ -125,7 +129,8 @@ async def test_one_download_exception_does_not_block_others(
# Slice 4: ETL error counts as download failure
async def test_etl_error_counts_as_download_failure(
- mock_onedrive_client, patch_extract,
+ mock_onedrive_client,
+ patch_extract,
):
files = [_make_file_dict("f0", "good.txt"), _make_file_dict("f1", "bad.txt")]
patch_extract(
@@ -150,7 +155,8 @@ async def test_etl_error_counts_as_download_failure(
# Slice 5: Semaphore bound
async def test_concurrency_bounded_by_semaphore(
- mock_onedrive_client, monkeypatch,
+ mock_onedrive_client,
+ monkeypatch,
):
lock = asyncio.Lock()
active = 0
@@ -190,7 +196,8 @@ async def test_concurrency_bounded_by_semaphore(
# Slice 6: Heartbeat fires
async def test_heartbeat_fires_during_parallel_downloads(
- mock_onedrive_client, monkeypatch,
+ mock_onedrive_client,
+ monkeypatch,
):
import app.tasks.connector_indexers.onedrive_indexer as _mod
diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx
index 918032acd..92ced6e47 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx
@@ -744,7 +744,11 @@ export function DocumentsTableShell({
- onOpenInTab ? onOpenInTab(doc) : handleViewDocument(doc)}>
+
+ onOpenInTab ? onOpenInTab(doc) : handleViewDocument(doc)
+ }
+ >
Open
diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
index 8c7b16c72..8928974d9 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
@@ -666,62 +666,62 @@ export default function NewChatPage() {
const scheduleFlush = () => batcher.schedule(flushMessages);
for await (const parsed of readSSEStream(response)) {
- switch (parsed.type) {
- case "text-delta":
- appendText(contentPartsState, parsed.delta);
- scheduleFlush();
- break;
+ switch (parsed.type) {
+ case "text-delta":
+ appendText(contentPartsState, parsed.delta);
+ scheduleFlush();
+ break;
- case "tool-input-start":
- addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
- batcher.flush();
- break;
+ case "tool-input-start":
+ addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
+ batcher.flush();
+ break;
- case "tool-input-available": {
- if (toolCallIndices.has(parsed.toolCallId)) {
- updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} });
- } else {
- addToolCall(
- contentPartsState,
- TOOLS_WITH_UI,
- parsed.toolCallId,
- parsed.toolName,
- parsed.input || {}
- );
+ case "tool-input-available": {
+ if (toolCallIndices.has(parsed.toolCallId)) {
+ updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} });
+ } else {
+ addToolCall(
+ contentPartsState,
+ TOOLS_WITH_UI,
+ parsed.toolCallId,
+ parsed.toolName,
+ parsed.input || {}
+ );
+ }
+ batcher.flush();
+ break;
}
- batcher.flush();
- break;
- }
- case "tool-output-available": {
- updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output });
- markInterruptsCompleted(contentParts);
- if (parsed.output?.status === "pending" && parsed.output?.podcast_id) {
- const idx = toolCallIndices.get(parsed.toolCallId);
- if (idx !== undefined) {
- const part = contentParts[idx];
- if (part?.type === "tool-call" && part.toolName === "generate_podcast") {
- setActivePodcastTaskId(String(parsed.output.podcast_id));
+ case "tool-output-available": {
+ updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output });
+ markInterruptsCompleted(contentParts);
+ if (parsed.output?.status === "pending" && parsed.output?.podcast_id) {
+ const idx = toolCallIndices.get(parsed.toolCallId);
+ if (idx !== undefined) {
+ const part = contentParts[idx];
+ if (part?.type === "tool-call" && part.toolName === "generate_podcast") {
+ setActivePodcastTaskId(String(parsed.output.podcast_id));
+ }
}
}
+ batcher.flush();
+ break;
}
- batcher.flush();
- break;
- }
- case "data-thinking-step": {
- const stepData = parsed.data as ThinkingStepData;
- if (stepData?.id) {
- currentThinkingSteps.set(stepData.id, stepData);
- const didUpdate = updateThinkingSteps(contentPartsState, currentThinkingSteps);
- if (didUpdate) {
- scheduleFlush();
+ case "data-thinking-step": {
+ const stepData = parsed.data as ThinkingStepData;
+ if (stepData?.id) {
+ currentThinkingSteps.set(stepData.id, stepData);
+ const didUpdate = updateThinkingSteps(contentPartsState, currentThinkingSteps);
+ if (didUpdate) {
+ scheduleFlush();
+ }
}
+ break;
}
- break;
- }
- case "data-thread-title-update": {
+ case "data-thread-title-update": {
const titleData = parsed.data as { threadId: number; title: string };
if (titleData?.title && titleData?.threadId === currentThreadId) {
setCurrentThread((prev) => (prev ? { ...prev, title: titleData.title } : prev));
@@ -1012,7 +1012,7 @@ export default function NewChatPage() {
throw new Error(`Backend error: ${response.status}`);
}
- const flushMessages = () => {
+ const flushMessages = () => {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
@@ -1024,55 +1024,55 @@ export default function NewChatPage() {
const scheduleFlush = () => batcher.schedule(flushMessages);
for await (const parsed of readSSEStream(response)) {
- switch (parsed.type) {
- case "text-delta":
- appendText(contentPartsState, parsed.delta);
- scheduleFlush();
- break;
+ switch (parsed.type) {
+ case "text-delta":
+ appendText(contentPartsState, parsed.delta);
+ scheduleFlush();
+ break;
- case "tool-input-start":
- addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
- batcher.flush();
- break;
+ case "tool-input-start":
+ addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
+ batcher.flush();
+ break;
- case "tool-input-available":
- if (toolCallIndices.has(parsed.toolCallId)) {
- updateToolCall(contentPartsState, parsed.toolCallId, {
- args: parsed.input || {},
- });
- } else {
- addToolCall(
- contentPartsState,
- TOOLS_WITH_UI,
- parsed.toolCallId,
- parsed.toolName,
- parsed.input || {}
- );
- }
- batcher.flush();
- break;
-
- case "tool-output-available":
- updateToolCall(contentPartsState, parsed.toolCallId, {
- result: parsed.output,
- });
- markInterruptsCompleted(contentParts);
- batcher.flush();
- break;
-
- case "data-thinking-step": {
- const stepData = parsed.data as ThinkingStepData;
- if (stepData?.id) {
- currentThinkingSteps.set(stepData.id, stepData);
- const didUpdate = updateThinkingSteps(contentPartsState, currentThinkingSteps);
- if (didUpdate) {
- scheduleFlush();
+ case "tool-input-available":
+ if (toolCallIndices.has(parsed.toolCallId)) {
+ updateToolCall(contentPartsState, parsed.toolCallId, {
+ args: parsed.input || {},
+ });
+ } else {
+ addToolCall(
+ contentPartsState,
+ TOOLS_WITH_UI,
+ parsed.toolCallId,
+ parsed.toolName,
+ parsed.input || {}
+ );
}
- }
- break;
- }
+ batcher.flush();
+ break;
- case "data-interrupt-request": {
+ case "tool-output-available":
+ updateToolCall(contentPartsState, parsed.toolCallId, {
+ result: parsed.output,
+ });
+ markInterruptsCompleted(contentParts);
+ batcher.flush();
+ break;
+
+ case "data-thinking-step": {
+ const stepData = parsed.data as ThinkingStepData;
+ if (stepData?.id) {
+ currentThinkingSteps.set(stepData.id, stepData);
+ const didUpdate = updateThinkingSteps(contentPartsState, currentThinkingSteps);
+ if (didUpdate) {
+ scheduleFlush();
+ }
+ }
+ break;
+ }
+
+ case "data-interrupt-request": {
const interruptData = parsed.data as Record;
const actionRequests = (interruptData.action_requests ?? []) as Array<{
name: string;
@@ -1330,7 +1330,7 @@ export default function NewChatPage() {
throw new Error(`Backend error: ${response.status}`);
}
- const flushMessages = () => {
+ const flushMessages = () => {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
@@ -1342,63 +1342,63 @@ export default function NewChatPage() {
const scheduleFlush = () => batcher.schedule(flushMessages);
for await (const parsed of readSSEStream(response)) {
- switch (parsed.type) {
- case "text-delta":
- appendText(contentPartsState, parsed.delta);
- scheduleFlush();
- break;
+ switch (parsed.type) {
+ case "text-delta":
+ appendText(contentPartsState, parsed.delta);
+ scheduleFlush();
+ break;
- case "tool-input-start":
- addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
- batcher.flush();
- break;
+ case "tool-input-start":
+ addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
+ batcher.flush();
+ break;
- case "tool-input-available":
- if (toolCallIndices.has(parsed.toolCallId)) {
- updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} });
- } else {
- addToolCall(
- contentPartsState,
- TOOLS_WITH_UI,
- parsed.toolCallId,
- parsed.toolName,
- parsed.input || {}
- );
- }
- batcher.flush();
- break;
+ case "tool-input-available":
+ if (toolCallIndices.has(parsed.toolCallId)) {
+ updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} });
+ } else {
+ addToolCall(
+ contentPartsState,
+ TOOLS_WITH_UI,
+ parsed.toolCallId,
+ parsed.toolName,
+ parsed.input || {}
+ );
+ }
+ batcher.flush();
+ break;
- case "tool-output-available":
- updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output });
- markInterruptsCompleted(contentParts);
- if (parsed.output?.status === "pending" && parsed.output?.podcast_id) {
- const idx = toolCallIndices.get(parsed.toolCallId);
- if (idx !== undefined) {
- const part = contentParts[idx];
- if (part?.type === "tool-call" && part.toolName === "generate_podcast") {
- setActivePodcastTaskId(String(parsed.output.podcast_id));
+ case "tool-output-available":
+ updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output });
+ markInterruptsCompleted(contentParts);
+ if (parsed.output?.status === "pending" && parsed.output?.podcast_id) {
+ const idx = toolCallIndices.get(parsed.toolCallId);
+ if (idx !== undefined) {
+ const part = contentParts[idx];
+ if (part?.type === "tool-call" && part.toolName === "generate_podcast") {
+ setActivePodcastTaskId(String(parsed.output.podcast_id));
+ }
}
}
- }
- batcher.flush();
- break;
+ batcher.flush();
+ break;
- case "data-thinking-step": {
- const stepData = parsed.data as ThinkingStepData;
- if (stepData?.id) {
- currentThinkingSteps.set(stepData.id, stepData);
- const didUpdate = updateThinkingSteps(contentPartsState, currentThinkingSteps);
- if (didUpdate) {
- scheduleFlush();
+ case "data-thinking-step": {
+ const stepData = parsed.data as ThinkingStepData;
+ if (stepData?.id) {
+ currentThinkingSteps.set(stepData.id, stepData);
+ const didUpdate = updateThinkingSteps(contentPartsState, currentThinkingSteps);
+ if (didUpdate) {
+ scheduleFlush();
+ }
}
+ break;
}
- break;
- }
- case "error":
- throw new Error(parsed.errorText || "Server error");
+ case "error":
+ throw new Error(parsed.errorText || "Server error");
+ }
}
- }
batcher.flush();
@@ -1547,4 +1547,4 @@ export default function NewChatPage() {
);
-}
\ No newline at end of file
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx
index 5e3f1cf7b..4dba3bbb6 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx
@@ -166,13 +166,13 @@ export default function OnboardPage() {
{/* Form card */}
-
Start Using SurfSense
{isSubmitting && }
-
- You can add more configurations later
-
+ You can add more configurations later
diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/team-content.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/team-content.tsx
index b6f008887..d9ca9efb3 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/team/team-content.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/team/team-content.tsx
@@ -308,7 +308,8 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) {
{invitesLoading ? (
) : (
- canInvite && activeInvites.length > 0 && (
+ canInvite &&
+ activeInvites.length > 0 && (
)
)}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx
index 104dc111f..c2d2c01de 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx
@@ -3,11 +3,11 @@
import { PenLine, Plus, Sparkles, Trash2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
-import type { PromptRead } from "@/contracts/types/prompts.types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
+import type { PromptRead } from "@/contracts/types/prompts.types";
import { promptsApiService } from "@/lib/apis/prompts-api.service";
interface PromptFormData {
@@ -99,7 +99,9 @@ export function PromptsContent() {
- Create prompt templates triggered with / in the chat composer.
+ Create prompt templates triggered with{" "}
+ / in the
+ chat composer.
{!showForm && (
- Use {"{selection}"} to insert the input text. If omitted, the text is appended automatically.
+ Use{" "}
+
+ {"{selection}"}
+ {" "}
+ to insert the input text. If omitted, the text is appended automatically.
@@ -153,7 +159,9 @@ export function PromptsContent() {
setFormData((p) => ({ ...p, mode: e.target.value as "transform" | "explore" }))}
+ onChange={(e) =>
+ setFormData((p) => ({ ...p, mode: e.target.value as "transform" | "explore" }))
+ }
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring"
>
Transform — rewrites or modifies your text
diff --git a/surfsense_web/atoms/new-llm-config/new-llm-config-mutation.atoms.ts b/surfsense_web/atoms/new-llm-config/new-llm-config-mutation.atoms.ts
index 00156b844..861606f80 100644
--- a/surfsense_web/atoms/new-llm-config/new-llm-config-mutation.atoms.ts
+++ b/surfsense_web/atoms/new-llm-config/new-llm-config-mutation.atoms.ts
@@ -78,7 +78,10 @@ export const deleteNewLLMConfigMutationAtom = atomWithMutation((get) => {
mutationFn: async (request: DeleteNewLLMConfigRequest & { name: string }) => {
return newLLMConfigApiService.deleteConfig({ id: request.id });
},
- onSuccess: (_: DeleteNewLLMConfigResponse, request: DeleteNewLLMConfigRequest & { name: string }) => {
+ onSuccess: (
+ _: DeleteNewLLMConfigResponse,
+ request: DeleteNewLLMConfigRequest & { name: string }
+ ) => {
toast.success(`${request.name} deleted`);
queryClient.setQueryData(
cacheKeys.newLLMConfigs.all(Number(searchSpaceId)),
diff --git a/surfsense_web/atoms/tabs/tabs.atom.ts b/surfsense_web/atoms/tabs/tabs.atom.ts
index 2d462e4d5..22cc5373a 100644
--- a/surfsense_web/atoms/tabs/tabs.atom.ts
+++ b/surfsense_web/atoms/tabs/tabs.atom.ts
@@ -143,9 +143,7 @@ export const updateChatTabTitleAtom = atom(
set(tabsStateAtom, {
...state,
activeTabId: tabId,
- tabs: state.tabs.map((t) =>
- t.id === "chat-new" ? { ...t, id: tabId, chatId, title } : t
- ),
+ tabs: state.tabs.map((t) => (t.id === "chat-new" ? { ...t, id: tabId, chatId, title } : t)),
});
return;
}
diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx
index 2223ff5dd..ba2753da8 100644
--- a/surfsense_web/components/assistant-ui/assistant-message.tsx
+++ b/surfsense_web/components/assistant-ui/assistant-message.tsx
@@ -7,7 +7,14 @@ import {
useAuiState,
} from "@assistant-ui/react";
import { useAtomValue } from "jotai";
-import { CheckIcon, ClipboardPaste, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react";
+import {
+ CheckIcon,
+ ClipboardPaste,
+ CopyIcon,
+ DownloadIcon,
+ MessageSquare,
+ RefreshCwIcon,
+} from "lucide-react";
import type { FC } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { commentsEnabledAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
@@ -41,10 +48,6 @@ import {
CreateGoogleDriveFileToolUI,
DeleteGoogleDriveFileToolUI,
} from "@/components/tool-ui/google-drive";
-import {
- CreateOneDriveFileToolUI,
- DeleteOneDriveFileToolUI,
-} from "@/components/tool-ui/onedrive";
import {
CreateJiraIssueToolUI,
DeleteJiraIssueToolUI,
@@ -60,6 +63,7 @@ import {
DeleteNotionPageToolUI,
UpdateNotionPageToolUI,
} from "@/components/tool-ui/notion";
+import { CreateOneDriveFileToolUI, DeleteOneDriveFileToolUI } from "@/components/tool-ui/onedrive";
import { SandboxExecuteToolUI } from "@/components/tool-ui/sandbox-execute";
import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory";
import { GenerateVideoPresentationToolUI } from "@/components/tool-ui/video-presentation";
@@ -117,10 +121,10 @@ const AssistantMessageInner: FC = () => {
create_confluence_page: CreateConfluencePageToolUI,
update_confluence_page: UpdateConfluencePageToolUI,
delete_confluence_page: DeleteConfluencePageToolUI,
- web_search: () => null,
- link_preview: () => null,
- multi_link_preview: () => null,
- scrape_webpage: () => null,
+ web_search: () => null,
+ link_preview: () => null,
+ multi_link_preview: () => null,
+ scrape_webpage: () => null,
},
Fallback: ToolFallback,
},
diff --git a/surfsense_web/components/assistant-ui/citation-metadata-context.tsx b/surfsense_web/components/assistant-ui/citation-metadata-context.tsx
index 7502b6951..b14d9ba4f 100644
--- a/surfsense_web/components/assistant-ui/citation-metadata-context.tsx
+++ b/surfsense_web/components/assistant-ui/citation-metadata-context.tsx
@@ -24,7 +24,9 @@ interface MessageContent {
}
export const CitationMetadataProvider: FC<{ children: ReactNode }> = ({ children }) => {
- const content = useAuiState(({ message }) => (message as { content?: MessageContent[] })?.content);
+ const content = useAuiState(
+ ({ message }) => (message as { content?: MessageContent[] })?.content
+ );
const metadataMap = useMemo(() => {
if (!content || !Array.isArray(content)) return new Map();
@@ -51,7 +53,9 @@ export const CitationMetadataProvider: FC<{ children: ReactNode }> = ({ children
}, [content]);
return (
- {children}
+
+ {children}
+
);
};
diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx
index 27868f5da..5a35016f5 100644
--- a/surfsense_web/components/assistant-ui/connector-popup.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup.tsx
@@ -198,7 +198,6 @@ export const ConnectorIndicator = forwardRef
-
{isOpen &&
createPortal(
{
const cfg = connectorConfig || editingConnector.config;
- const isDriveOrOneDrive =
- editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
- editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
- editingConnector.connector_type === "ONEDRIVE_CONNECTOR";
- const hasDriveItems = isDriveOrOneDrive
+ const isDriveOrOneDrive =
+ editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
+ editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
+ editingConnector.connector_type === "ONEDRIVE_CONNECTOR";
+ const hasDriveItems = isDriveOrOneDrive
? ((cfg?.selected_folders as unknown[]) ?? []).length > 0 ||
((cfg?.selected_files as unknown[]) ?? []).length > 0
: true;
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/onedrive-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/onedrive-config.tsx
index 250a353cd..dc8ec3ded 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/onedrive-config.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/onedrive-config.tsx
@@ -212,8 +212,7 @@ export const OneDriveConfig: FC
= ({ connector, onConfigCh
{isAuthExpired && (
- Your OneDrive authentication has expired. Please re-authenticate using the button
- below.
+ Your OneDrive authentication has expired. Please re-authenticate using the button below.
)}
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx
index ba43ce823..605de93b7 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx
@@ -19,9 +19,9 @@ import { LinkupApiConfig } from "./components/linkup-api-config";
import { LumaConfig } from "./components/luma-config";
import { MCPConfig } from "./components/mcp-config";
import { ObsidianConfig } from "./components/obsidian-config";
+import { OneDriveConfig } from "./components/onedrive-config";
import { SlackConfig } from "./components/slack-config";
import { TavilyApiConfig } from "./components/tavily-api-config";
-import { OneDriveConfig } from "./components/onedrive-config";
import { TeamsConfig } from "./components/teams-config";
import { WebcrawlerConfig } from "./components/webcrawler-config";
diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts
index 0ee34d7c2..e5ce803c1 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts
+++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts
@@ -779,11 +779,11 @@ export const useConnectorDialog = () => {
});
}
- // Handle Google Drive / OneDrive folder selection (regular and Composio)
- if (
- (indexingConfig.connectorType === "GOOGLE_DRIVE_CONNECTOR" ||
- indexingConfig.connectorType === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
- indexingConfig.connectorType === "ONEDRIVE_CONNECTOR") &&
+ // Handle Google Drive / OneDrive folder selection (regular and Composio)
+ if (
+ (indexingConfig.connectorType === "GOOGLE_DRIVE_CONNECTOR" ||
+ indexingConfig.connectorType === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
+ indexingConfig.connectorType === "ONEDRIVE_CONNECTOR") &&
indexingConnectorConfig
) {
const selectedFolders = indexingConnectorConfig.selected_folders as
diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx
index b8a0febbe..af7a8397c 100644
--- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx
+++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx
@@ -544,7 +544,12 @@ export const InlineMentionEditor = forwardRef
{children}
diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx
index 5d513a6ad..3826f8a80 100644
--- a/surfsense_web/components/assistant-ui/thread.tsx
+++ b/surfsense_web/components/assistant-ui/thread.tsx
@@ -61,11 +61,11 @@ import {
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { UserMessage } from "@/components/assistant-ui/user-message";
import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/components/layout/ui/sidebar/SidebarSlideOutPanel";
-import { PromptPicker, type PromptPickerRef } from "@/components/new-chat/prompt-picker";
import {
DocumentMentionPicker,
type DocumentMentionPickerRef,
} from "@/components/new-chat/document-mention-picker";
+import { PromptPicker, type PromptPickerRef } from "@/components/new-chat/prompt-picker";
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
@@ -356,7 +356,9 @@ const Composer: FC = () => {
const submitCleanupRef = useRef<(() => void) | null>(null);
useEffect(() => {
- return () => { submitCleanupRef.current?.(); };
+ return () => {
+ submitCleanupRef.current?.();
+ };
}, []);
const [clipboardInitialText, setClipboardInitialText] = useState();
@@ -498,7 +500,9 @@ const Composer: FC = () => {
}
const finalPrompt = action.prompt.includes("{selection}")
? action.prompt.replace("{selection}", () => userText)
- : userText ? `${action.prompt}\n\n${userText}` : action.prompt;
+ : userText
+ ? `${action.prompt}\n\n${userText}`
+ : action.prompt;
aui.composer().setText(finalPrompt);
aui.composer().send();
editorRef.current?.clear();
@@ -590,9 +594,7 @@ const Composer: FC = () => {
if (!showDocumentPopover && !showPromptPicker) {
if (clipboardInitialText) {
const userText = editorRef.current?.getText() ?? "";
- const combined = userText
- ? `${userText}\n\n${clipboardInitialText}`
- : clipboardInitialText;
+ const combined = userText ? `${userText}\n\n${clipboardInitialText}` : clipboardInitialText;
aui.composer().setText(combined);
setClipboardInitialText(undefined);
}
@@ -706,7 +708,7 @@ const Composer: FC = () => {
return (
{
currentUserId={currentUser?.id ?? null}
members={members ?? []}
/>
-
+
{clipboardInitialText && (
{
position: "fixed",
...(clipboardInitialText && composerBoxRef.current
? { top: `${composerBoxRef.current.getBoundingClientRect().bottom + 8}px` }
- : { bottom: editorContainerRef.current
- ? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 8}px`
- : "200px" }
- ),
+ : {
+ bottom: editorContainerRef.current
+ ? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 8}px`
+ : "200px",
+ }),
left: editorContainerRef.current
? `${editorContainerRef.current.getBoundingClientRect().left}px`
: "50%",
diff --git a/surfsense_web/components/documents/FolderTreeView.tsx b/surfsense_web/components/documents/FolderTreeView.tsx
index a8397e2b5..f63d5da5c 100644
--- a/surfsense_web/components/documents/FolderTreeView.tsx
+++ b/surfsense_web/components/documents/FolderTreeView.tsx
@@ -240,7 +240,9 @@ export function FolderTreeView({
return (
No documents found
-
Use the upload button or connect a source above
+
+ Use the upload button or connect a source above
+
);
}
diff --git a/surfsense_web/components/editor-panel/editor-panel.tsx b/surfsense_web/components/editor-panel/editor-panel.tsx
index aff1367b0..308ad158b 100644
--- a/surfsense_web/components/editor-panel/editor-panel.tsx
+++ b/surfsense_web/components/editor-panel/editor-panel.tsx
@@ -5,8 +5,8 @@ import { AlertCircle, XIcon } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
-import { MarkdownViewer } from "@/components/markdown-viewer";
import { PlateEditor } from "@/components/editor/plate-editor";
+import { MarkdownViewer } from "@/components/markdown-viewer";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
import { useMediaQuery } from "@/hooks/use-media-query";
diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
index 3db53285b..abc73425e 100644
--- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
+++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
@@ -20,7 +20,12 @@ import {
teamDialogAtom,
userSettingsDialogAtom,
} from "@/atoms/settings/settings-dialog.atoms";
-import { removeChatTabAtom, resetTabsAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
+import {
+ removeChatTabAtom,
+ resetTabsAtom,
+ syncChatTabAtom,
+ type Tab,
+} from "@/atoms/tabs/tabs.atom";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { MorePagesDialog } from "@/components/settings/more-pages-dialog";
import { SearchSpaceSettingsDialog } from "@/components/settings/search-space-settings-dialog";
@@ -846,7 +851,9 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
disabled={isRenamingChat || !newChatTitle.trim()}
className="relative"
>
- {tSidebar("rename") || "Rename"}
+
+ {tSidebar("rename") || "Rename"}
+
{isRenamingChat && (
)}
diff --git a/surfsense_web/components/layout/ui/header/Header.tsx b/surfsense_web/components/layout/ui/header/Header.tsx
index c28fecbbe..cdf7a4fc4 100644
--- a/surfsense_web/components/layout/ui/header/Header.tsx
+++ b/surfsense_web/components/layout/ui/header/Header.tsx
@@ -2,8 +2,8 @@
import { useAtomValue } from "jotai";
import { usePathname } from "next/navigation";
-import { hitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
+import { hitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { reportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
import { editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
@@ -37,7 +37,8 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
const reportOpen = reportState.isOpen && !!reportState.reportId;
const editorOpen = editorState.isOpen && !!editorState.documentId;
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
- const showExpandButton = !isMobile && collapsed && (documentsOpen || reportOpen || editorOpen || hitlEditOpen);
+ const showExpandButton =
+ !isMobile && collapsed && (documentsOpen || reportOpen || editorOpen || hitlEditOpen);
const hasTabBar = tabs.length > 1;
const currentThreadState = useAtomValue(currentThreadAtom);
@@ -71,7 +72,9 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
{/* Right side - Actions */}
-
+
{hasThread && (
)}
diff --git a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx
index 01e397309..1bc773374 100644
--- a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx
@@ -1,8 +1,8 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
-import { useSetAtom } from "jotai";
import { format } from "date-fns";
+import { useSetAtom } from "jotai";
import {
ArchiveIcon,
ChevronLeft,
@@ -19,6 +19,7 @@ import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
+import { removeChatTabAtom } from "@/atoms/tabs/tabs.atom";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/animated-tabs";
import { Button } from "@/components/ui/button";
import {
@@ -42,7 +43,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useLongPress } from "@/hooks/use-long-press";
import { useIsMobile } from "@/hooks/use-mobile";
-import { removeChatTabAtom } from "@/atoms/tabs/tabs.atom";
import {
deleteThread,
fetchThreads,
diff --git a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx
index b13bf2ba3..b2ba86673 100644
--- a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx
@@ -1,8 +1,8 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
-import { useSetAtom } from "jotai";
import { format } from "date-fns";
+import { useSetAtom } from "jotai";
import {
ArchiveIcon,
ChevronLeft,
@@ -19,6 +19,7 @@ import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
+import { removeChatTabAtom } from "@/atoms/tabs/tabs.atom";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/animated-tabs";
import { Button } from "@/components/ui/button";
import {
@@ -42,7 +43,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useLongPress } from "@/hooks/use-long-press";
import { useIsMobile } from "@/hooks/use-mobile";
-import { removeChatTabAtom } from "@/atoms/tabs/tabs.atom";
import {
deleteThread,
fetchThreads,
diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx
index 2d3cbbe53..a4661a860 100644
--- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx
@@ -12,10 +12,10 @@ import { sidebarSelectedDocumentsAtom } from "@/atoms/chat/mentioned-documents.a
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
-import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { expandedFolderIdsAtom } from "@/atoms/documents/folder.atoms";
-import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
import { agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms";
+import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
+import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog";
import type { DocumentNodeDoc } from "@/components/documents/DocumentNode";
import type { FolderDisplay } from "@/components/documents/FolderNode";
diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
index db8b10fe9..f484ca5e5 100644
--- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
@@ -105,8 +105,8 @@ export function Sidebar({
>
{/* Header - search space name or collapse button when collapsed */}
{isCollapsed ? (
-
-
+ {})}
disableTooltip={disableTooltips}
diff --git a/surfsense_web/components/layout/ui/tabs/TabBar.tsx b/surfsense_web/components/layout/ui/tabs/TabBar.tsx
index c38b84df9..0de438040 100644
--- a/surfsense_web/components/layout/ui/tabs/TabBar.tsx
+++ b/surfsense_web/components/layout/ui/tabs/TabBar.tsx
@@ -58,7 +58,12 @@ export function TabBar({ onTabSwitch, onNewChat, className }: TabBarProps) {
if (tabs.length <= 1) return null;
return (
-
+
= {
zap:
,
};
-const DEFAULT_ACTIONS: { name: string; prompt: string; mode: "transform" | "explore"; icon: string }[] = [
- { name: "Fix grammar", prompt: "Fix the grammar and spelling in the following text. Return only the corrected text, nothing else.\n\n{selection}", mode: "transform", icon: "check" },
- { name: "Make shorter", prompt: "Make the following text more concise while preserving its meaning. Return only the shortened text, nothing else.\n\n{selection}", mode: "transform", icon: "minimize" },
- { name: "Translate", prompt: "Translate the following text to English. If it is already in English, translate it to French. Return only the translation, nothing else.\n\n{selection}", mode: "transform", icon: "languages" },
- { name: "Rewrite", prompt: "Rewrite the following text to improve clarity and readability. Return only the rewritten text, nothing else.\n\n{selection}", mode: "transform", icon: "pen-line" },
- { name: "Summarize", prompt: "Summarize the following text concisely. Return only the summary, nothing else.\n\n{selection}", mode: "transform", icon: "list" },
- { name: "Explain", prompt: "Explain the following text in simple terms:\n\n{selection}", mode: "explore", icon: "book-open" },
- { name: "Ask my knowledge base", prompt: "Search my knowledge base for information related to:\n\n{selection}", mode: "explore", icon: "search" },
- { name: "Look up on the web", prompt: "Search the web for information about:\n\n{selection}", mode: "explore", icon: "globe" },
+const DEFAULT_ACTIONS: {
+ name: string;
+ prompt: string;
+ mode: "transform" | "explore";
+ icon: string;
+}[] = [
+ {
+ name: "Fix grammar",
+ prompt:
+ "Fix the grammar and spelling in the following text. Return only the corrected text, nothing else.\n\n{selection}",
+ mode: "transform",
+ icon: "check",
+ },
+ {
+ name: "Make shorter",
+ prompt:
+ "Make the following text more concise while preserving its meaning. Return only the shortened text, nothing else.\n\n{selection}",
+ mode: "transform",
+ icon: "minimize",
+ },
+ {
+ name: "Translate",
+ prompt:
+ "Translate the following text to English. If it is already in English, translate it to French. Return only the translation, nothing else.\n\n{selection}",
+ mode: "transform",
+ icon: "languages",
+ },
+ {
+ name: "Rewrite",
+ prompt:
+ "Rewrite the following text to improve clarity and readability. Return only the rewritten text, nothing else.\n\n{selection}",
+ mode: "transform",
+ icon: "pen-line",
+ },
+ {
+ name: "Summarize",
+ prompt:
+ "Summarize the following text concisely. Return only the summary, nothing else.\n\n{selection}",
+ mode: "transform",
+ icon: "list",
+ },
+ {
+ name: "Explain",
+ prompt: "Explain the following text in simple terms:\n\n{selection}",
+ mode: "explore",
+ icon: "book-open",
+ },
+ {
+ name: "Ask my knowledge base",
+ prompt: "Search my knowledge base for information related to:\n\n{selection}",
+ mode: "explore",
+ icon: "search",
+ },
+ {
+ name: "Look up on the web",
+ prompt: "Search the web for information about:\n\n{selection}",
+ mode: "explore",
+ icon: "globe",
+ },
];
-export const PromptPicker = forwardRef
(
- function PromptPicker({ onSelect, onDone, externalSearch = "", containerStyle }, ref) {
- const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom);
- const [highlightedIndex, setHighlightedIndex] = useState(0);
- const [customPrompts, setCustomPrompts] = useState([]);
- const scrollContainerRef = useRef(null);
- const shouldScrollRef = useRef(false);
- const itemRefs = useRef>(new Map());
+export const PromptPicker = forwardRef(function PromptPicker(
+ { onSelect, onDone, externalSearch = "", containerStyle },
+ ref
+) {
+ const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom);
+ const [highlightedIndex, setHighlightedIndex] = useState(0);
+ const [customPrompts, setCustomPrompts] = useState([]);
+ const scrollContainerRef = useRef(null);
+ const shouldScrollRef = useRef(false);
+ const itemRefs = useRef>(new Map());
- useEffect(() => {
- promptsApiService.list().then(setCustomPrompts).catch(() => {});
- }, []);
+ useEffect(() => {
+ promptsApiService
+ .list()
+ .then(setCustomPrompts)
+ .catch(() => {});
+ }, []);
- const allActions = useMemo(() => {
- const customs = customPrompts.map((a) => ({
- name: a.name,
- prompt: a.prompt,
- mode: a.mode as "transform" | "explore",
- icon: a.icon || "zap",
- }));
- return [...DEFAULT_ACTIONS, ...customs];
- }, [customPrompts]);
+ const allActions = useMemo(() => {
+ const customs = customPrompts.map((a) => ({
+ name: a.name,
+ prompt: a.prompt,
+ mode: a.mode as "transform" | "explore",
+ icon: a.icon || "zap",
+ }));
+ return [...DEFAULT_ACTIONS, ...customs];
+ }, [customPrompts]);
- const filtered = useMemo(() => {
- if (!externalSearch) return allActions;
- return allActions.filter((a) =>
- a.name.toLowerCase().includes(externalSearch.toLowerCase())
- );
- }, [allActions, externalSearch]);
+ const filtered = useMemo(() => {
+ if (!externalSearch) return allActions;
+ return allActions.filter((a) => a.name.toLowerCase().includes(externalSearch.toLowerCase()));
+ }, [allActions, externalSearch]);
- // Reset highlight when results change
- const prevSearchRef = useRef(externalSearch);
- if (prevSearchRef.current !== externalSearch) {
- prevSearchRef.current = externalSearch;
- if (highlightedIndex !== 0) {
- setHighlightedIndex(0);
- }
+ // Reset highlight when results change
+ const prevSearchRef = useRef(externalSearch);
+ if (prevSearchRef.current !== externalSearch) {
+ prevSearchRef.current = externalSearch;
+ if (highlightedIndex !== 0) {
+ setHighlightedIndex(0);
}
+ }
- const handleSelect = useCallback(
- (index: number) => {
- const action = filtered[index];
- if (!action) return;
- onSelect({ name: action.name, prompt: action.prompt, mode: action.mode });
- },
- [filtered, onSelect]
- );
+ const handleSelect = useCallback(
+ (index: number) => {
+ const action = filtered[index];
+ if (!action) return;
+ onSelect({ name: action.name, prompt: action.prompt, mode: action.mode });
+ },
+ [filtered, onSelect]
+ );
- // Auto-scroll highlighted item into view
- useEffect(() => {
- if (!shouldScrollRef.current) return;
- shouldScrollRef.current = false;
+ // Auto-scroll highlighted item into view
+ useEffect(() => {
+ if (!shouldScrollRef.current) return;
+ shouldScrollRef.current = false;
- const rafId = requestAnimationFrame(() => {
- const item = itemRefs.current.get(highlightedIndex);
- const container = scrollContainerRef.current;
- if (item && container) {
- const itemRect = item.getBoundingClientRect();
- const containerRect = container.getBoundingClientRect();
- if (itemRect.top < containerRect.top || itemRect.bottom > containerRect.bottom) {
- item.scrollIntoView({ block: "nearest" });
- }
+ const rafId = requestAnimationFrame(() => {
+ const item = itemRefs.current.get(highlightedIndex);
+ const container = scrollContainerRef.current;
+ if (item && container) {
+ const itemRect = item.getBoundingClientRect();
+ const containerRect = container.getBoundingClientRect();
+ if (itemRect.top < containerRect.top || itemRect.bottom > containerRect.bottom) {
+ item.scrollIntoView({ block: "nearest" });
}
- });
+ }
+ });
- return () => cancelAnimationFrame(rafId);
- }, [highlightedIndex]);
+ return () => cancelAnimationFrame(rafId);
+ }, [highlightedIndex]);
- useImperativeHandle(
- ref,
- () => ({
- selectHighlighted: () => handleSelect(highlightedIndex),
- moveUp: () => {
- shouldScrollRef.current = true;
- setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1));
- },
- moveDown: () => {
- shouldScrollRef.current = true;
- setHighlightedIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0));
- },
- }),
- [filtered.length, highlightedIndex, handleSelect]
- );
+ useImperativeHandle(
+ ref,
+ () => ({
+ selectHighlighted: () => handleSelect(highlightedIndex),
+ moveUp: () => {
+ shouldScrollRef.current = true;
+ setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1));
+ },
+ moveDown: () => {
+ shouldScrollRef.current = true;
+ setHighlightedIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0));
+ },
+ }),
+ [filtered.length, highlightedIndex, handleSelect]
+ );
- if (filtered.length === 0) return null;
+ if (filtered.length === 0) return null;
- const defaultFiltered = filtered.filter((_, i) => i < DEFAULT_ACTIONS.length);
- const customFiltered = filtered.filter((_, i) => i >= DEFAULT_ACTIONS.length);
+ const defaultFiltered = filtered.filter((_, i) => i < DEFAULT_ACTIONS.length);
+ const customFiltered = filtered.filter((_, i) => i >= DEFAULT_ACTIONS.length);
- return (
-
-
- {defaultFiltered.map((action, index) => (
+ return (
+
+
+ {defaultFiltered.map((action, index) => (
+
{
+ if (el) itemRefs.current.set(index, el);
+ else itemRefs.current.delete(index);
+ }}
+ type="button"
+ onClick={() => handleSelect(index)}
+ onMouseEnter={() => setHighlightedIndex(index)}
+ className={cn(
+ "flex w-full items-center gap-2 px-3 py-1.5 text-sm cursor-pointer",
+ index === highlightedIndex ? "bg-accent" : "hover:bg-accent/50"
+ )}
+ >
+
+ {ICONS[action.icon] ?? }
+
+ {action.name}
+
+ ))}
+
+ {customFiltered.length > 0 &&
}
+
+ {customFiltered.map((action, i) => {
+ const index = defaultFiltered.length + i;
+ return (
{
@@ -174,52 +253,27 @@ export const PromptPicker = forwardRef(
index === highlightedIndex ? "bg-accent" : "hover:bg-accent/50"
)}
>
- {ICONS[action.icon] ?? }
+
+
+
{action.name}
- ))}
+ );
+ })}
- {customFiltered.length > 0 && (
-
- )}
-
- {customFiltered.map((action, i) => {
- const index = defaultFiltered.length + i;
- return (
-
{
- if (el) itemRefs.current.set(index, el);
- else itemRefs.current.delete(index);
- }}
- type="button"
- onClick={() => handleSelect(index)}
- onMouseEnter={() => setHighlightedIndex(index)}
- className={cn(
- "flex w-full items-center gap-2 px-3 py-1.5 text-sm cursor-pointer",
- index === highlightedIndex ? "bg-accent" : "hover:bg-accent/50"
- )}
- >
-
- {action.name}
-
- );
- })}
-
-
-
{
- onDone();
- setUserSettingsDialog({ open: true, initialTab: "prompts" });
- }}
- className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-accent/50 cursor-pointer"
- >
-
- Create prompt
-
-
+
+
{
+ onDone();
+ setUserSettingsDialog({ open: true, initialTab: "prompts" });
+ }}
+ className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-accent/50 cursor-pointer"
+ >
+
+ Create prompt
+
- );
- }
-);
+
+ );
+});
diff --git a/surfsense_web/components/public-chat/public-thread.tsx b/surfsense_web/components/public-chat/public-thread.tsx
index 837350a84..8678cef52 100644
--- a/surfsense_web/components/public-chat/public-thread.tsx
+++ b/surfsense_web/components/public-chat/public-thread.tsx
@@ -155,10 +155,10 @@ const PublicAssistantMessage: FC = () => {
generate_video_presentation: GenerateVideoPresentationToolUI,
display_image: GenerateImageToolUI,
generate_image: GenerateImageToolUI,
- web_search: () => null,
- link_preview: () => null,
- multi_link_preview: () => null,
- scrape_webpage: () => null,
+ web_search: () => null,
+ link_preview: () => null,
+ multi_link_preview: () => null,
+ scrape_webpage: () => null,
},
Fallback: ToolFallback,
},
diff --git a/surfsense_web/components/settings/image-model-manager.tsx b/surfsense_web/components/settings/image-model-manager.tsx
index b5a98dcbe..8f08b7db3 100644
--- a/surfsense_web/components/settings/image-model-manager.tsx
+++ b/surfsense_web/components/settings/image-model-manager.tsx
@@ -1,24 +1,15 @@
"use client";
import { useAtomValue } from "jotai";
-import {
- AlertCircle,
- Edit3,
- Info,
- Plus,
- RefreshCw,
- Trash2,
- Wand2,
-} from "lucide-react";
+import { AlertCircle, Edit3, Info, Plus, RefreshCw, Trash2, Wand2 } from "lucide-react";
import { useMemo, useState } from "react";
-import {
- deleteImageGenConfigMutationAtom,
-} from "@/atoms/image-gen-config/image-gen-config-mutation.atoms";
+import { deleteImageGenConfigMutationAtom } from "@/atoms/image-gen-config/image-gen-config-mutation.atoms";
import {
globalImageGenConfigsAtom,
imageGenConfigsAtom,
} from "@/atoms/image-gen-config/image-gen-config-query.atoms";
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
+import { ImageConfigDialog } from "@/components/shared/image-config-dialog";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
AlertDialog,
@@ -40,7 +31,6 @@ import type { ImageGenerationConfig } from "@/contracts/types/new-llm-config.typ
import { useMediaQuery } from "@/hooks/use-media-query";
import { getProviderIcon } from "@/lib/provider-icons";
import { cn } from "@/lib/utils";
-import { ImageConfigDialog } from "@/components/shared/image-config-dialog";
interface ImageModelManagerProps {
searchSpaceId: number;
@@ -196,7 +186,16 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
- {globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length} global image {globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length === 1 ? "model" : "models"} available from your administrator. Use the model selector to view and select them.
+
+
+ {globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length}{" "}
+ global image{" "}
+ {globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length === 1
+ ? "model"
+ : "models"}
+ {" "}
+ available from your administrator. Use the model selector to view and select them.
+
)}
@@ -399,10 +398,8 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
onOpenChange={(open) => !open && setConfigToDelete(null)}
>
-
-
- Delete Image Model
-
+
+ Delete Image Model
Are you sure you want to delete{" "}
{configToDelete?.name} ?
@@ -410,14 +407,14 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
Cancel
-
- Delete
- {isDeleting && }
-
+
+ Delete
+ {isDeleting && }
+
diff --git a/surfsense_web/components/settings/model-config-manager.tsx b/surfsense_web/components/settings/model-config-manager.tsx
index 409aa4f3c..046288a96 100644
--- a/surfsense_web/components/settings/model-config-manager.tsx
+++ b/surfsense_web/components/settings/model-config-manager.tsx
@@ -14,9 +14,7 @@ import {
} from "lucide-react";
import { useMemo, useState } from "react";
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
-import {
- deleteNewLLMConfigMutationAtom,
-} from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
+import { deleteNewLLMConfigMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
import {
globalNewLLMConfigsAtom,
newLLMConfigsAtom,
@@ -203,7 +201,12 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
- {globalConfigs.length} global {globalConfigs.length === 1 ? "model" : "models"} available from your administrator. Use the model selector to view and select them.
+
+
+ {globalConfigs.length} global {globalConfigs.length === 1 ? "model" : "models"}
+ {" "}
+ available from your administrator. Use the model selector to view and select them.
+
)}
@@ -433,9 +436,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
>
-
- Delete LLM Model
-
+ Delete LLM Model
Are you sure you want to delete{" "}
{configToDelete?.name} ? This
@@ -449,14 +450,14 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
- {isDeleting ? (
- <>
-
- Deleting
- >
- ) : (
- "Delete"
- )}
+ {isDeleting ? (
+ <>
+
+ Deleting
+ >
+ ) : (
+ "Delete"
+ )}
diff --git a/surfsense_web/components/shared/image-config-dialog.tsx b/surfsense_web/components/shared/image-config-dialog.tsx
index fe8a73df6..1cfbf8842 100644
--- a/surfsense_web/components/shared/image-config-dialog.tsx
+++ b/surfsense_web/components/shared/image-config-dialog.tsx
@@ -217,9 +217,7 @@ export function ImageConfigDialog({
{getSubtitle()}
{config && mode !== "create" && (
-
- {config.model_name}
-
+ {config.model_name}
)}
@@ -234,7 +232,7 @@ export function ImageConfigDialog({
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
}}
>
- {isGlobal && config && (
+ {isGlobal && config && (
<>
@@ -294,9 +292,7 @@ export function ImageConfigDialog({
- setFormData((p) => ({ ...p, description: e.target.value }))
- }
+ onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
/>
@@ -337,17 +333,12 @@ export function ImageConfigDialog({
-
+
- setFormData((p) => ({ ...p, model_name: val }))
- }
+ onValueChange={(val) => setFormData((p) => ({ ...p, model_name: val }))}
/>
@@ -368,9 +359,7 @@ export function ImageConfigDialog({
{m.value}
@@ -388,9 +377,7 @@ export function ImageConfigDialog({
- setFormData((p) => ({ ...p, model_name: e.target.value }))
- }
+ onChange={(e) => setFormData((p) => ({ ...p, model_name: e.target.value }))}
/>
)}
@@ -420,9 +407,7 @@ export function ImageConfigDialog({
- setFormData((p) => ({ ...p, api_version: e.target.value }))
- }
+ onChange={(e) => setFormData((p) => ({ ...p, api_version: e.target.value }))}
/>
)}
@@ -442,25 +427,25 @@ export function ImageConfigDialog({
Cancel
{mode === "create" || (mode === "edit" && !isGlobal) ? (
-
-
- {mode === "edit" ? "Save Changes" : "Create & Use"}
-
- {isSubmitting && }
-
- ) : isGlobal && config ? (
-
- Use This Model
- {isSubmitting && }
-
+
+
+ {mode === "edit" ? "Save Changes" : "Create & Use"}
+
+ {isSubmitting && }
+
+ ) : isGlobal && config ? (
+
+ Use This Model
+ {isSubmitting && }
+
) : null}
diff --git a/surfsense_web/components/shared/llm-config-form.tsx b/surfsense_web/components/shared/llm-config-form.tsx
index 9fb8b9208..732bf971e 100644
--- a/surfsense_web/components/shared/llm-config-form.tsx
+++ b/surfsense_web/components/shared/llm-config-form.tsx
@@ -4,7 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useAtomValue } from "jotai";
import { Check, ChevronDown, ChevronsUpDown } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
-import { useForm, type Resolver } from "react-hook-form";
+import { type Resolver, useForm } from "react-hook-form";
import { z } from "zod";
import {
defaultSystemInstructionsAtom,
@@ -219,26 +219,22 @@ export function LLMConfigForm({
)}
/>
- {/* Custom Provider (conditional) */}
- {watchProvider === "CUSTOM" && (
-
(
-
- Custom Provider Name
-
-
-
-
-
- )}
- />
- )}
+ {/* Custom Provider (conditional) */}
+ {watchProvider === "CUSTOM" && (
+ (
+
+ Custom Provider Name
+
+
+
+
+
+ )}
+ />
+ )}
{/* Model Name with Combobox */}
- {/* Ollama Quick Actions */}
- {watchProvider === "OLLAMA" && (
-
- form.setValue("api_base", "http://localhost:11434")}
- >
- localhost:11434
-
- form.setValue("api_base", "http://host.docker.internal:11434")}
- >
- Docker
-
-
- )}
+ {/* Ollama Quick Actions */}
+ {watchProvider === "OLLAMA" && (
+
+ form.setValue("api_base", "http://localhost:11434")}
+ >
+ localhost:11434
+
+ form.setValue("api_base", "http://host.docker.internal:11434")}
+ >
+ Docker
+
+
+ )}
{/* Advanced Parameters */}
diff --git a/surfsense_web/components/shared/model-config-dialog.tsx b/surfsense_web/components/shared/model-config-dialog.tsx
index d5405574b..84ba821fc 100644
--- a/surfsense_web/components/shared/model-config-dialog.tsx
+++ b/surfsense_web/components/shared/model-config-dialog.tsx
@@ -167,9 +167,7 @@ export function ModelConfigDialog({
{getSubtitle()}
{config && mode !== "create" && (
-
- {config.model_name}
-
+ {config.model_name}
)}
@@ -184,7 +182,7 @@ export function ModelConfigDialog({
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
}}
>
- {isGlobal && mode !== "create" && (
+ {isGlobal && mode !== "create" && (
@@ -195,13 +193,13 @@ export function ModelConfigDialog({
)}
{mode === "create" ? (
-
- ) : isGlobal && config ? (
+
+ ) : isGlobal && config ? (
@@ -288,9 +286,9 @@ export function ModelConfigDialog({
citations_enabled: config.citations_enabled,
search_space_id: searchSpaceId,
}}
- onSubmit={handleSubmit}
- mode="edit"
- formId="model-config-form"
+ onSubmit={handleSubmit}
+ mode="edit"
+ formId="model-config-form"
/>
) : null}
@@ -307,26 +305,26 @@ export function ModelConfigDialog({
Cancel
{mode === "create" || (!isGlobal && config) ? (
-
-
- {mode === "edit" ? "Save Changes" : "Create & Use"}
-
- {isSubmitting && }
-
- ) : isGlobal && config ? (
-
- Use This Model
- {isSubmitting && }
-
+
+
+ {mode === "edit" ? "Save Changes" : "Create & Use"}
+
+ {isSubmitting && }
+
+ ) : isGlobal && config ? (
+
+ Use This Model
+ {isSubmitting && }
+
) : null}
diff --git a/surfsense_web/components/tool-ui/citation/_adapter.tsx b/surfsense_web/components/tool-ui/citation/_adapter.tsx
index 06ee62f6f..ba8ea5080 100644
--- a/surfsense_web/components/tool-ui/citation/_adapter.tsx
+++ b/surfsense_web/components/tool-ui/citation/_adapter.tsx
@@ -1,8 +1,8 @@
"use client";
-export { cn } from "@/lib/utils";
export {
- Popover,
- PopoverContent,
- PopoverTrigger,
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
} from "@/components/ui/popover";
+export { cn } from "@/lib/utils";
diff --git a/surfsense_web/components/tool-ui/citation/citation-list.tsx b/surfsense_web/components/tool-ui/citation/citation-list.tsx
index 34b995aae..3151917b6 100644
--- a/surfsense_web/components/tool-ui/citation/citation-list.tsx
+++ b/surfsense_web/components/tool-ui/citation/citation-list.tsx
@@ -1,463 +1,395 @@
"use client";
-import * as React from "react";
import type { LucideIcon } from "lucide-react";
-import {
- FileText,
- Globe,
- Code2,
- Newspaper,
- Database,
- File,
- ExternalLink,
-} from "lucide-react";
+import { Code2, Database, ExternalLink, File, FileText, Globe, Newspaper } from "lucide-react";
+import * as React from "react";
+import { openSafeNavigationHref, resolveSafeNavigationHref } from "../shared/media";
import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter";
import { Citation } from "./citation";
-import type {
- SerializableCitation,
- CitationType,
- CitationVariant,
-} from "./schema";
-import {
- openSafeNavigationHref,
- resolveSafeNavigationHref,
-} from "../shared/media";
+import type { CitationType, CitationVariant, SerializableCitation } from "./schema";
const TYPE_ICONS: Record
= {
- webpage: Globe,
- document: FileText,
- article: Newspaper,
- api: Database,
- code: Code2,
- other: File,
+ webpage: Globe,
+ document: FileText,
+ article: Newspaper,
+ api: Database,
+ code: Code2,
+ other: File,
};
function useHoverPopover(delay = 100) {
- const [open, setOpen] = React.useState(false);
- const timeoutRef = React.useRef | null>(null);
- const containerRef = React.useRef(null);
+ const [open, setOpen] = React.useState(false);
+ const timeoutRef = React.useRef | null>(null);
+ const containerRef = React.useRef(null);
- const handleMouseEnter = React.useCallback(() => {
- if (timeoutRef.current) clearTimeout(timeoutRef.current);
- timeoutRef.current = setTimeout(() => setOpen(true), delay);
- }, [delay]);
+ const handleMouseEnter = React.useCallback(() => {
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ timeoutRef.current = setTimeout(() => setOpen(true), delay);
+ }, [delay]);
- const handleMouseLeave = React.useCallback(() => {
- if (timeoutRef.current) clearTimeout(timeoutRef.current);
- timeoutRef.current = setTimeout(() => setOpen(false), delay);
- }, [delay]);
+ const handleMouseLeave = React.useCallback(() => {
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ timeoutRef.current = setTimeout(() => setOpen(false), delay);
+ }, [delay]);
- const handleFocus = React.useCallback(() => {
- if (timeoutRef.current) clearTimeout(timeoutRef.current);
- setOpen(true);
- }, []);
+ const handleFocus = React.useCallback(() => {
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ setOpen(true);
+ }, []);
- const handleBlur = React.useCallback(
- (e: React.FocusEvent) => {
- const relatedTarget = e.relatedTarget as HTMLElement | null;
- if (containerRef.current?.contains(relatedTarget)) {
- return;
- }
- if (relatedTarget?.closest("[data-radix-popper-content-wrapper]")) {
- return;
- }
- if (timeoutRef.current) clearTimeout(timeoutRef.current);
- timeoutRef.current = setTimeout(() => setOpen(false), delay);
- },
- [delay],
- );
+ const handleBlur = React.useCallback(
+ (e: React.FocusEvent) => {
+ const relatedTarget = e.relatedTarget as HTMLElement | null;
+ if (containerRef.current?.contains(relatedTarget)) {
+ return;
+ }
+ if (relatedTarget?.closest("[data-radix-popper-content-wrapper]")) {
+ return;
+ }
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ timeoutRef.current = setTimeout(() => setOpen(false), delay);
+ },
+ [delay]
+ );
- React.useEffect(() => {
- return () => {
- if (timeoutRef.current) clearTimeout(timeoutRef.current);
- };
- }, []);
+ React.useEffect(() => {
+ return () => {
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ };
+ }, []);
- return {
- open,
- setOpen,
- containerRef,
- handleMouseEnter,
- handleMouseLeave,
- handleFocus,
- handleBlur,
- };
+ return {
+ open,
+ setOpen,
+ containerRef,
+ handleMouseEnter,
+ handleMouseLeave,
+ handleFocus,
+ handleBlur,
+ };
}
export interface CitationListProps {
- id: string;
- citations: SerializableCitation[];
- variant?: CitationVariant;
- maxVisible?: number;
- className?: string;
- onNavigate?: (href: string, citation: SerializableCitation) => void;
+ id: string;
+ citations: SerializableCitation[];
+ variant?: CitationVariant;
+ maxVisible?: number;
+ className?: string;
+ onNavigate?: (href: string, citation: SerializableCitation) => void;
}
export function CitationList(props: CitationListProps) {
- const {
- id,
- citations,
- variant = "default",
- maxVisible,
- className,
- onNavigate,
- } = props;
+ const { id, citations, variant = "default", maxVisible, className, onNavigate } = props;
- const shouldTruncate =
- maxVisible !== undefined && citations.length > maxVisible;
- const visibleCitations = shouldTruncate
- ? citations.slice(0, maxVisible)
- : citations;
- const overflowCitations = shouldTruncate ? citations.slice(maxVisible) : [];
- const overflowCount = overflowCitations.length;
+ const shouldTruncate = maxVisible !== undefined && citations.length > maxVisible;
+ const visibleCitations = shouldTruncate ? citations.slice(0, maxVisible) : citations;
+ const overflowCitations = shouldTruncate ? citations.slice(maxVisible) : [];
+ const overflowCount = overflowCitations.length;
- const wrapperClass =
- variant === "inline"
- ? "flex flex-wrap items-center gap-1.5"
- : "flex flex-col gap-2";
+ const wrapperClass =
+ variant === "inline" ? "flex flex-wrap items-center gap-1.5" : "flex flex-col gap-2";
- // Stacked variant: overlapping favicons with popover
- if (variant === "stacked") {
- return (
-
- );
- }
+ // Stacked variant: overlapping favicons with popover
+ if (variant === "stacked") {
+ return (
+
+ );
+ }
- if (variant === "default") {
- return (
-
- {visibleCitations.map((citation) => (
-
- ))}
- {shouldTruncate && (
-
- )}
-
- );
- }
+ if (variant === "default") {
+ return (
+
+ {visibleCitations.map((citation) => (
+
+ ))}
+ {shouldTruncate && (
+
+ )}
+
+ );
+ }
- return (
-
- {visibleCitations.map((citation) => (
-
- ))}
- {shouldTruncate && (
-
- )}
-
- );
+ return (
+
+ {visibleCitations.map((citation) => (
+
+ ))}
+ {shouldTruncate && (
+
+ )}
+
+ );
}
interface OverflowIndicatorProps {
- citations: SerializableCitation[];
- count: number;
- variant: CitationVariant;
- onNavigate?: (href: string, citation: SerializableCitation) => void;
+ citations: SerializableCitation[];
+ count: number;
+ variant: CitationVariant;
+ onNavigate?: (href: string, citation: SerializableCitation) => void;
}
-function OverflowIndicator({
- citations,
- count,
- variant,
- onNavigate,
-}: OverflowIndicatorProps) {
- const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover();
+function OverflowIndicator({ citations, count, variant, onNavigate }: OverflowIndicatorProps) {
+ const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover();
- const handleClick = (citation: SerializableCitation) => {
- const href = resolveSafeNavigationHref(citation.href);
- if (!href) return;
- if (onNavigate) {
- onNavigate(href, citation);
- } else {
- openSafeNavigationHref(href);
- }
- };
+ const handleClick = (citation: SerializableCitation) => {
+ const href = resolveSafeNavigationHref(citation.href);
+ if (!href) return;
+ if (onNavigate) {
+ onNavigate(href, citation);
+ } else {
+ openSafeNavigationHref(href);
+ }
+ };
- const popoverContent = (
-
- {citations.map((citation) => (
- handleClick(citation)}
- />
- ))}
-
- );
+ const popoverContent = (
+
+ {citations.map((citation) => (
+ handleClick(citation)} />
+ ))}
+
+ );
- if (variant === "inline") {
- return (
-
-
-
- +{count} more
-
-
- e.preventDefault()}
- >
- {popoverContent}
-
-
- );
- }
+ if (variant === "inline") {
+ return (
+
+
+
+ +{count} more
+
+
+ e.preventDefault()}
+ >
+ {popoverContent}
+
+
+ );
+ }
- // Default variant
- return (
-
-
-
-
- +{count} more sources
-
-
-
- e.preventDefault()}
- >
- {popoverContent}
-
-
- );
+ // Default variant
+ return (
+
+
+
+ +{count} more sources
+
+
+ e.preventDefault()}
+ >
+ {popoverContent}
+
+
+ );
}
interface OverflowItemProps {
- citation: SerializableCitation;
- onClick: () => void;
+ citation: SerializableCitation;
+ onClick: () => void;
}
function OverflowItem({ citation, onClick }: OverflowItemProps) {
- const TypeIcon = TYPE_ICONS[citation.type ?? "webpage"] ?? Globe;
+ const TypeIcon = TYPE_ICONS[citation.type ?? "webpage"] ?? Globe;
- return (
-
- {citation.favicon ? (
- // biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config
-
- ) : (
-
- )}
-
-
- {citation.title}
-
-
- {citation.domain}
-
-
-
-
- );
+ return (
+
+ {citation.favicon ? (
+ // biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config
+
+ ) : (
+
+ )}
+
+
+ {citation.title}
+
+
{citation.domain}
+
+
+
+ );
}
interface StackedCitationsProps {
- id: string;
- citations: SerializableCitation[];
- className?: string;
- onNavigate?: (href: string, citation: SerializableCitation) => void;
+ id: string;
+ citations: SerializableCitation[];
+ className?: string;
+ onNavigate?: (href: string, citation: SerializableCitation) => void;
}
-function StackedCitations({
- id,
- citations,
- className,
- onNavigate,
-}: StackedCitationsProps) {
- const {
- open,
- setOpen,
- containerRef,
- handleMouseEnter,
- handleMouseLeave,
- handleBlur,
- } = useHoverPopover();
- const maxIcons = 4;
- const visibleCitations = citations.slice(0, maxIcons);
- const remainingCount = Math.max(0, citations.length - maxIcons);
+function StackedCitations({ id, citations, className, onNavigate }: StackedCitationsProps) {
+ const { open, setOpen, containerRef, handleMouseEnter, handleMouseLeave, handleBlur } =
+ useHoverPopover();
+ const maxIcons = 4;
+ const visibleCitations = citations.slice(0, maxIcons);
+ const remainingCount = Math.max(0, citations.length - maxIcons);
- const handleClick = (citation: SerializableCitation) => {
- const href = resolveSafeNavigationHref(citation.href);
- if (!href) return;
- if (onNavigate) {
- onNavigate(href, citation);
- } else {
- openSafeNavigationHref(href);
- }
- };
+ const handleClick = (citation: SerializableCitation) => {
+ const href = resolveSafeNavigationHref(citation.href);
+ if (!href) return;
+ if (onNavigate) {
+ onNavigate(href, citation);
+ } else {
+ openSafeNavigationHref(href);
+ }
+ };
- return (
- // biome-ignore lint/a11y/noStaticElementInteractions: blur boundary for popover focus management
-
-
-
- {
- if (e.key === "Enter" || e.key === " ") {
- e.preventDefault();
- setOpen(true);
- }
- }}
- className={cn(
- "isolate inline-flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2",
- "bg-muted/40 outline-none",
- "transition-colors duration-150",
- "hover:bg-muted/70",
- "focus-visible:ring-ring focus-visible:ring-2",
- className,
- )}
- >
-
- {visibleCitations.map((citation, index) => {
- const TypeIcon =
- TYPE_ICONS[citation.type ?? "webpage"] ?? Globe;
- return (
-
0 && "-ml-2",
- )}
- style={{ zIndex: maxIcons - index }}
- >
- {citation.favicon ? (
- // biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config
-
- ) : (
-
- )}
-
- );
- })}
- {remainingCount > 0 && (
-
-
- •••
-
-
- )}
-
-
- {citations.length} source{citations.length !== 1 && "s"}
-
-
-
- setOpen(false)}
- >
-
- {citations.map((citation) => (
- handleClick(citation)}
- />
- ))}
-
-
-
-
- );
+ return (
+ // biome-ignore lint/a11y/noStaticElementInteractions: blur boundary for popover focus management
+
+
+
+ {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ setOpen(true);
+ }
+ }}
+ className={cn(
+ "isolate inline-flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2",
+ "bg-muted/40 outline-none",
+ "transition-colors duration-150",
+ "hover:bg-muted/70",
+ "focus-visible:ring-ring focus-visible:ring-2",
+ className
+ )}
+ >
+
+ {visibleCitations.map((citation, index) => {
+ const TypeIcon = TYPE_ICONS[citation.type ?? "webpage"] ?? Globe;
+ return (
+
0 && "-ml-2"
+ )}
+ style={{ zIndex: maxIcons - index }}
+ >
+ {citation.favicon ? (
+ // biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config
+
+ ) : (
+
+ )}
+
+ );
+ })}
+ {remainingCount > 0 && (
+
+
+ •••
+
+
+ )}
+
+
+ {citations.length} source{citations.length !== 1 && "s"}
+
+
+
+ setOpen(false)}
+ >
+
+ {citations.map((citation) => (
+ handleClick(citation)}
+ />
+ ))}
+
+
+
+
+ );
}
diff --git a/surfsense_web/components/tool-ui/citation/citation.tsx b/surfsense_web/components/tool-ui/citation/citation.tsx
index dcecb7fa3..523169f49 100644
--- a/surfsense_web/components/tool-ui/citation/citation.tsx
+++ b/surfsense_web/components/tool-ui/citation/citation.tsx
@@ -1,261 +1,248 @@
"use client";
-import * as React from "react";
import type { LucideIcon } from "lucide-react";
-import {
- FileText,
- Globe,
- Code2,
- Newspaper,
- Database,
- File,
- ExternalLink,
-} from "lucide-react";
-import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter";
-
+import { Code2, Database, ExternalLink, File, FileText, Globe, Newspaper } from "lucide-react";
+import * as React from "react";
import { openSafeNavigationHref, sanitizeHref } from "../shared/media";
-import type {
- SerializableCitation,
- CitationType,
- CitationVariant,
-} from "./schema";
+import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter";
+import type { CitationType, CitationVariant, SerializableCitation } from "./schema";
const FALLBACK_LOCALE = "en-US";
const TYPE_ICONS: Record = {
- webpage: Globe,
- document: FileText,
- article: Newspaper,
- api: Database,
- code: Code2,
- other: File,
+ webpage: Globe,
+ document: FileText,
+ article: Newspaper,
+ api: Database,
+ code: Code2,
+ other: File,
};
function extractDomain(url: string): string | undefined {
- try {
- const urlObj = new URL(url);
- return urlObj.hostname.replace(/^www\./, "");
- } catch {
- return undefined;
- }
+ try {
+ const urlObj = new URL(url);
+ return urlObj.hostname.replace(/^www\./, "");
+ } catch {
+ return undefined;
+ }
}
function formatDate(isoString: string, locale: string): string {
- try {
- const date = new Date(isoString);
- return date.toLocaleDateString(locale, {
- year: "numeric",
- month: "short",
- });
- } catch {
- return isoString;
- }
+ try {
+ const date = new Date(isoString);
+ return date.toLocaleDateString(locale, {
+ year: "numeric",
+ month: "short",
+ });
+ } catch {
+ return isoString;
+ }
}
function useHoverPopover(delay = 100) {
- const [open, setOpen] = React.useState(false);
- const timeoutRef = React.useRef | null>(null);
+ const [open, setOpen] = React.useState(false);
+ const timeoutRef = React.useRef | null>(null);
- const handleMouseEnter = React.useCallback(() => {
- if (timeoutRef.current) clearTimeout(timeoutRef.current);
- timeoutRef.current = setTimeout(() => setOpen(true), delay);
- }, [delay]);
+ const handleMouseEnter = React.useCallback(() => {
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ timeoutRef.current = setTimeout(() => setOpen(true), delay);
+ }, [delay]);
- const handleMouseLeave = React.useCallback(() => {
- if (timeoutRef.current) clearTimeout(timeoutRef.current);
- timeoutRef.current = setTimeout(() => setOpen(false), delay);
- }, [delay]);
+ const handleMouseLeave = React.useCallback(() => {
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ timeoutRef.current = setTimeout(() => setOpen(false), delay);
+ }, [delay]);
- React.useEffect(() => {
- return () => {
- if (timeoutRef.current) clearTimeout(timeoutRef.current);
- };
- }, []);
+ React.useEffect(() => {
+ return () => {
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ };
+ }, []);
- return { open, setOpen, handleMouseEnter, handleMouseLeave };
+ return { open, setOpen, handleMouseEnter, handleMouseLeave };
}
export interface CitationProps extends SerializableCitation {
- variant?: CitationVariant;
- className?: string;
- onNavigate?: (href: string, citation: SerializableCitation) => void;
+ variant?: CitationVariant;
+ className?: string;
+ onNavigate?: (href: string, citation: SerializableCitation) => void;
}
export function Citation(props: CitationProps) {
- const { variant = "default", className, onNavigate, ...serializable } = props;
+ const { variant = "default", className, onNavigate, ...serializable } = props;
- const {
- id,
- href: rawHref,
- title,
- snippet,
- domain: providedDomain,
- favicon,
- author,
- publishedAt,
- type = "webpage",
- locale: providedLocale,
- } = serializable;
+ const {
+ id,
+ href: rawHref,
+ title,
+ snippet,
+ domain: providedDomain,
+ favicon,
+ author,
+ publishedAt,
+ type = "webpage",
+ locale: providedLocale,
+ } = serializable;
- const locale = providedLocale ?? FALLBACK_LOCALE;
- const sanitizedHref = sanitizeHref(rawHref);
- const domain = providedDomain ?? extractDomain(rawHref);
+ const locale = providedLocale ?? FALLBACK_LOCALE;
+ const sanitizedHref = sanitizeHref(rawHref);
+ const domain = providedDomain ?? extractDomain(rawHref);
- const citationData: SerializableCitation = {
- ...serializable,
- href: sanitizedHref ?? rawHref,
- domain,
- locale,
- };
+ const citationData: SerializableCitation = {
+ ...serializable,
+ href: sanitizedHref ?? rawHref,
+ domain,
+ locale,
+ };
- const TypeIcon = TYPE_ICONS[type] ?? Globe;
+ const TypeIcon = TYPE_ICONS[type] ?? Globe;
- const handleClick = () => {
- if (!sanitizedHref) return;
- if (onNavigate) {
- onNavigate(sanitizedHref, citationData);
- } else {
- openSafeNavigationHref(sanitizedHref);
- }
- };
+ const handleClick = () => {
+ if (!sanitizedHref) return;
+ if (onNavigate) {
+ onNavigate(sanitizedHref, citationData);
+ } else {
+ openSafeNavigationHref(sanitizedHref);
+ }
+ };
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (sanitizedHref && (e.key === "Enter" || e.key === " ")) {
- e.preventDefault();
- handleClick();
- }
- };
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (sanitizedHref && (e.key === "Enter" || e.key === " ")) {
+ e.preventDefault();
+ handleClick();
+ }
+ };
- const iconElement = favicon ? (
- // biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config
-
- ) : (
-
- );
+ const iconElement = favicon ? (
+ // biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config
+
+ ) : (
+
+ );
- const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover();
+ const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover();
- // Inline variant: compact chip with hover popover
- if (variant === "inline") {
- return (
-
-
-
- {iconElement}
- {domain}
-
-
- e.preventDefault()}
- onCloseAutoFocus={(e) => e.preventDefault()}
- onClick={handleClick}
- >
-
-
- {iconElement}
- {domain}
-
-
{title}
- {snippet && (
-
- {snippet}
-
- )}
-
-
-
- );
- }
+ // Inline variant: compact chip with hover popover
+ if (variant === "inline") {
+ return (
+
+
+
+ {iconElement}
+ {domain}
+
+
+ e.preventDefault()}
+ onCloseAutoFocus={(e) => e.preventDefault()}
+ onClick={handleClick}
+ >
+
+
+ {iconElement}
+ {domain}
+
+
{title}
+ {snippet && (
+
+ {snippet}
+
+ )}
+
+
+
+ );
+ }
- // Default variant: full card
- return (
-
- {/* biome-ignore lint/a11y/noStaticElementInteractions: div receives role="link" conditionally when href is present */}
-
-
-
-
- {iconElement}
- {domain}
- {(author || publishedAt) && (
-
- —
- {author}
- {author && publishedAt && ", "}
- {publishedAt && (
-
- {formatDate(publishedAt, locale)}
-
- )}
-
- )}
-
- {sanitizedHref && (
-
- )}
-
+ // Default variant: full card
+ return (
+
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: div receives role="link" conditionally when href is present */}
+
+
+
+
+ {iconElement}
+ {domain}
+ {(author || publishedAt) && (
+
+ —
+ {author}
+ {author && publishedAt && ", "}
+ {publishedAt && (
+
+ {formatDate(publishedAt, locale)}
+
+ )}
+
+ )}
+
+ {sanitizedHref && (
+
+ )}
+
-
-
- {title}
-
-
+
+
+ {title}
+
+
- {snippet && (
-
- {snippet}
-
- )}
-
-
-
- );
+ {snippet && (
+
+ {snippet}
+
+ )}
+
+
+
+ );
}
diff --git a/surfsense_web/components/tool-ui/citation/index.ts b/surfsense_web/components/tool-ui/citation/index.ts
index 11df62e89..2abcc5746 100644
--- a/surfsense_web/components/tool-ui/citation/index.ts
+++ b/surfsense_web/components/tool-ui/citation/index.ts
@@ -1,9 +1,9 @@
-export { Citation } from "./citation";
export type { CitationProps } from "./citation";
-export { CitationList } from "./citation-list";
+export { Citation } from "./citation";
export type { CitationListProps } from "./citation-list";
+export { CitationList } from "./citation-list";
export type {
- SerializableCitation,
- CitationType,
- CitationVariant,
+ CitationType,
+ CitationVariant,
+ SerializableCitation,
} from "./schema";
diff --git a/surfsense_web/components/tool-ui/citation/schema.ts b/surfsense_web/components/tool-ui/citation/schema.ts
index d6db58018..d03eb2d22 100644
--- a/surfsense_web/components/tool-ui/citation/schema.ts
+++ b/surfsense_web/components/tool-ui/citation/schema.ts
@@ -1,17 +1,13 @@
import { z } from "zod";
-import {
- ToolUIIdSchema,
- ToolUIReceiptSchema,
- ToolUIRoleSchema,
-} from "../shared/schema";
+import { ToolUIIdSchema, ToolUIReceiptSchema, ToolUIRoleSchema } from "../shared/schema";
export const CitationTypeSchema = z.enum([
- "webpage",
- "document",
- "article",
- "api",
- "code",
- "other",
+ "webpage",
+ "document",
+ "article",
+ "api",
+ "code",
+ "other",
]);
export type CitationType = z.infer;
@@ -21,18 +17,18 @@ export const CitationVariantSchema = z.enum(["default", "inline", "stacked"]);
export type CitationVariant = z.infer;
export const SerializableCitationSchema = z.object({
- id: ToolUIIdSchema,
- role: ToolUIRoleSchema.optional(),
- receipt: ToolUIReceiptSchema.optional(),
- href: z.string().url(),
- title: z.string(),
- snippet: z.string().optional(),
- domain: z.string().optional(),
- favicon: z.string().url().optional(),
- author: z.string().optional(),
- publishedAt: z.string().datetime().optional(),
- type: CitationTypeSchema.optional(),
- locale: z.string().optional(),
+ id: ToolUIIdSchema,
+ role: ToolUIRoleSchema.optional(),
+ receipt: ToolUIReceiptSchema.optional(),
+ href: z.string().url(),
+ title: z.string(),
+ snippet: z.string().optional(),
+ domain: z.string().optional(),
+ favicon: z.string().url().optional(),
+ author: z.string().optional(),
+ publishedAt: z.string().datetime().optional(),
+ type: CitationTypeSchema.optional(),
+ locale: z.string().optional(),
});
export type SerializableCitation = z.infer;
diff --git a/surfsense_web/components/tool-ui/index.ts b/surfsense_web/components/tool-ui/index.ts
index 2e4ea82ef..517a8a290 100644
--- a/surfsense_web/components/tool-ui/index.ts
+++ b/surfsense_web/components/tool-ui/index.ts
@@ -17,7 +17,6 @@ export {
export { GeneratePodcastToolUI } from "./generate-podcast";
export { GenerateReportToolUI } from "./generate-report";
export { CreateGoogleDriveFileToolUI, DeleteGoogleDriveFileToolUI } from "./google-drive";
-export { CreateOneDriveFileToolUI, DeleteOneDriveFileToolUI } from "./onedrive";
export {
Image,
ImageErrorBoundary,
@@ -33,6 +32,7 @@ export {
UpdateLinearIssueToolUI,
} from "./linear";
export { CreateNotionPageToolUI, DeleteNotionPageToolUI, UpdateNotionPageToolUI } from "./notion";
+export { CreateOneDriveFileToolUI, DeleteOneDriveFileToolUI } from "./onedrive";
export {
Plan,
PlanErrorBoundary,
diff --git a/surfsense_web/components/tool-ui/onedrive/create-file.tsx b/surfsense_web/components/tool-ui/onedrive/create-file.tsx
index c75be7f7f..d66f04d24 100644
--- a/surfsense_web/components/tool-ui/onedrive/create-file.tsx
+++ b/surfsense_web/components/tool-ui/onedrive/create-file.tsx
@@ -270,9 +270,7 @@ function ApprovalCard({
)}
-
- File Type
-
+
File Type
@@ -315,11 +313,25 @@ function ApprovalCard({
{(pendingEdits?.name ?? args.name) != null && (
-
{String(pendingEdits?.name ?? args.name)}
+
+ {String(pendingEdits?.name ?? args.name)}
+
)}
{(pendingEdits?.content ?? args.content) != null && (
-
@@ -329,12 +341,26 @@ function ApprovalCard({
{allowedDecisions.includes("approve") && (
-
+
Approve
)}
{allowedDecisions.includes("reject") && (
- { setRejected(); onDecision({ type: "reject", message: "User rejected the action." }); }}>
+ {
+ setRejected();
+ onDecision({ type: "reject", message: "User rejected the action." });
+ }}
+ >
Reject
)}
@@ -352,7 +378,9 @@ function ErrorCard({ result }: { result: ErrorResult }) {
Failed to create OneDrive file
-
+
);
}
@@ -364,7 +392,9 @@ function AuthErrorCard({ result }: { result: AuthErrorResult }) {
OneDrive authentication expired
-
+
);
}
@@ -373,7 +403,9 @@ function SuccessCard({ result }: { result: SuccessResult }) {
return (
-
{result.message || "OneDrive file created successfully"}
+
+ {result.message || "OneDrive file created successfully"}
+
@@ -383,7 +415,14 @@ function SuccessCard({ result }: { result: SuccessResult }) {
{result.web_url && (
)}
@@ -391,12 +430,31 @@ function SuccessCard({ result }: { result: SuccessResult }) {
);
}
-export const CreateOneDriveFileToolUI = ({ args, result }: ToolCallMessagePartProps<{ name: string; content?: string }, CreateOneDriveFileResult>) => {
+export const CreateOneDriveFileToolUI = ({
+ args,
+ result,
+}: ToolCallMessagePartProps<{ name: string; content?: string }, CreateOneDriveFileResult>) => {
if (!result) return null;
if (isInterruptResult(result)) {
- return { window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })); }} />;
+ return (
+ {
+ window.dispatchEvent(
+ new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
+ );
+ }}
+ />
+ );
}
- if (typeof result === "object" && result !== null && "status" in result && (result as { status: string }).status === "rejected") return null;
+ if (
+ typeof result === "object" &&
+ result !== null &&
+ "status" in result &&
+ (result as { status: string }).status === "rejected"
+ )
+ return null;
if (isAuthErrorResult(result)) return ;
if (isErrorResult(result)) return ;
return ;
diff --git a/surfsense_web/components/tool-ui/onedrive/trash-file.tsx b/surfsense_web/components/tool-ui/onedrive/trash-file.tsx
index b5efd4fab..0b887865f 100644
--- a/surfsense_web/components/tool-ui/onedrive/trash-file.tsx
+++ b/surfsense_web/components/tool-ui/onedrive/trash-file.tsx
@@ -31,29 +31,76 @@ interface InterruptResult {
context?: { account?: OneDriveAccount; file?: OneDriveFile; error?: string };
}
-interface SuccessResult { status: "success"; file_id: string; message?: string; deleted_from_kb?: boolean }
-interface ErrorResult { status: "error"; message: string }
-interface NotFoundResult { status: "not_found"; message: string }
-interface AuthErrorResult { status: "auth_error"; message: string; connector_type?: string }
+interface SuccessResult {
+ status: "success";
+ file_id: string;
+ message?: string;
+ deleted_from_kb?: boolean;
+}
+interface ErrorResult {
+ status: "error";
+ message: string;
+}
+interface NotFoundResult {
+ status: "not_found";
+ message: string;
+}
+interface AuthErrorResult {
+ status: "auth_error";
+ message: string;
+ connector_type?: string;
+}
-type DeleteOneDriveFileResult = InterruptResult | SuccessResult | ErrorResult | NotFoundResult | AuthErrorResult;
+type DeleteOneDriveFileResult =
+ | InterruptResult
+ | SuccessResult
+ | ErrorResult
+ | NotFoundResult
+ | AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
- return typeof result === "object" && result !== null && "__interrupt__" in result && (result as InterruptResult).__interrupt__ === true;
+ return (
+ typeof result === "object" &&
+ result !== null &&
+ "__interrupt__" in result &&
+ (result as InterruptResult).__interrupt__ === true
+ );
}
function isErrorResult(result: unknown): result is ErrorResult {
- return typeof result === "object" && result !== null && "status" in result && (result as ErrorResult).status === "error";
+ return (
+ typeof result === "object" &&
+ result !== null &&
+ "status" in result &&
+ (result as ErrorResult).status === "error"
+ );
}
function isNotFoundResult(result: unknown): result is NotFoundResult {
- return typeof result === "object" && result !== null && "status" in result && (result as NotFoundResult).status === "not_found";
+ return (
+ typeof result === "object" &&
+ result !== null &&
+ "status" in result &&
+ (result as NotFoundResult).status === "not_found"
+ );
}
function isAuthErrorResult(result: unknown): result is AuthErrorResult {
- return typeof result === "object" && result !== null && "status" in result && (result as AuthErrorResult).status === "auth_error";
+ return (
+ typeof result === "object" &&
+ result !== null &&
+ "status" in result &&
+ (result as AuthErrorResult).status === "auth_error"
+ );
}
-function ApprovalCard({ interruptData, onDecision }: {
+function ApprovalCard({
+ interruptData,
+ onDecision,
+}: {
interruptData: InterruptResult;
- onDecision: (decision: { type: "approve" | "reject"; message?: string; edited_action?: { name: string; args: Record } }) => void;
+ onDecision: (decision: {
+ type: "approve" | "reject";
+ message?: string;
+ edited_action?: { name: string; args: Record };
+ }) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [deleteFromKb, setDeleteFromKb] = useState(false);
@@ -87,7 +134,11 @@ function ApprovalCard({ interruptData, onDecision }: {
- {phase === "rejected" ? "OneDrive File Deletion Rejected" : phase === "processing" || phase === "complete" ? "OneDrive File Deletion Approved" : "Delete OneDrive File"}
+ {phase === "rejected"
+ ? "OneDrive File Deletion Rejected"
+ : phase === "processing" || phase === "complete"
+ ? "OneDrive File Deletion Approved"
+ : "Delete OneDrive File"}
{phase === "processing" ? (
@@ -96,7 +147,9 @@ function ApprovalCard({ interruptData, onDecision }: {
) : phase === "rejected" ? (
File deletion was cancelled
) : (
-
Requires your approval to proceed
+
+ Requires your approval to proceed
+
)}
@@ -112,7 +165,9 @@ function ApprovalCard({ interruptData, onDecision }: {
{account && (
OneDrive Account
-
{account.name}
+
+ {account.name}
+
)}
{file && (
@@ -121,7 +176,14 @@ function ApprovalCard({ interruptData, onDecision }: {
@@ -136,12 +198,21 @@ function ApprovalCard({ interruptData, onDecision }: {
<>
-
The file will be moved to the OneDrive recycle bin. You can restore it within 93 days.
+
+ The file will be moved to the OneDrive recycle bin. You can restore it within 93 days.
+
-
setDeleteFromKb(v === true)} className="shrink-0" />
+ setDeleteFromKb(v === true)}
+ className="shrink-0"
+ />
Also remove from knowledge base
- This will permanently delete the file from your knowledge base
+
+ This will permanently delete the file from your knowledge base
+
@@ -152,8 +223,20 @@ function ApprovalCard({ interruptData, onDecision }: {
<>
- Approve
- { setRejected(); onDecision({ type: "reject", message: "User rejected the action." }); }}>Reject
+
+ Approve
+
+ {
+ setRejected();
+ onDecision({ type: "reject", message: "User rejected the action." });
+ }}
+ >
+ Reject
+
>
)}
@@ -164,9 +247,13 @@ function ApprovalCard({ interruptData, onDecision }: {
function ErrorCard({ result }: { result: ErrorResult }) {
return (
-
+
+
Failed to delete file
+
-
+
);
}
@@ -185,9 +272,13 @@ function NotFoundCard({ result }: { result: NotFoundResult }) {
function AuthErrorCard({ result }: { result: AuthErrorResult }) {
return (
-
OneDrive authentication expired
+
+
OneDrive authentication expired
+
-
+
);
}
@@ -195,23 +286,51 @@ function AuthErrorCard({ result }: { result: AuthErrorResult }) {
function SuccessCard({ result }: { result: SuccessResult }) {
return (
-
{result.message || "File moved to recycle bin"}
+
+
+ {result.message || "File moved to recycle bin"}
+
+
{result.deleted_from_kb && (
<>
-
Also removed from knowledge base
+
+
+ Also removed from knowledge base
+
+
>
)}
);
}
-export const DeleteOneDriveFileToolUI = ({ result }: ToolCallMessagePartProps<{ file_name: string; delete_from_kb?: boolean }, DeleteOneDriveFileResult>) => {
+export const DeleteOneDriveFileToolUI = ({
+ result,
+}: ToolCallMessagePartProps<
+ { file_name: string; delete_from_kb?: boolean },
+ DeleteOneDriveFileResult
+>) => {
if (!result) return null;
if (isInterruptResult(result)) {
- return { window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })); }} />;
+ return (
+ {
+ window.dispatchEvent(
+ new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
+ );
+ }}
+ />
+ );
}
- if (typeof result === "object" && result !== null && "status" in result && (result as { status: string }).status === "rejected") return null;
+ if (
+ typeof result === "object" &&
+ result !== null &&
+ "status" in result &&
+ (result as { status: string }).status === "rejected"
+ )
+ return null;
if (isAuthErrorResult(result)) return ;
if (isNotFoundResult(result)) return ;
if (isErrorResult(result)) return ;
diff --git a/surfsense_web/components/tool-ui/shared/media/index.ts b/surfsense_web/components/tool-ui/shared/media/index.ts
index 5a3166335..d08f59cec 100644
--- a/surfsense_web/components/tool-ui/shared/media/index.ts
+++ b/surfsense_web/components/tool-ui/shared/media/index.ts
@@ -1,5 +1,5 @@
-export { sanitizeHref } from "./sanitize-href";
export {
- resolveSafeNavigationHref,
- openSafeNavigationHref,
+ openSafeNavigationHref,
+ resolveSafeNavigationHref,
} from "./safe-navigation";
+export { sanitizeHref } from "./sanitize-href";
diff --git a/surfsense_web/components/tool-ui/shared/media/safe-navigation.ts b/surfsense_web/components/tool-ui/shared/media/safe-navigation.ts
index b1f38bf48..899c2d2b2 100644
--- a/surfsense_web/components/tool-ui/shared/media/safe-navigation.ts
+++ b/surfsense_web/components/tool-ui/shared/media/safe-navigation.ts
@@ -1,23 +1,23 @@
import { sanitizeHref } from "./sanitize-href";
export function resolveSafeNavigationHref(
- ...candidates: Array
+ ...candidates: Array
): string | undefined {
- for (const candidate of candidates) {
- const safeHref = sanitizeHref(candidate ?? undefined);
- if (safeHref) {
- return safeHref;
- }
- }
+ for (const candidate of candidates) {
+ const safeHref = sanitizeHref(candidate ?? undefined);
+ if (safeHref) {
+ return safeHref;
+ }
+ }
- return undefined;
+ return undefined;
}
export function openSafeNavigationHref(href: string | undefined): boolean {
- if (!href || typeof window === "undefined") {
- return false;
- }
+ if (!href || typeof window === "undefined") {
+ return false;
+ }
- window.open(href, "_blank", "noopener,noreferrer");
- return true;
+ window.open(href, "_blank", "noopener,noreferrer");
+ return true;
}
diff --git a/surfsense_web/components/tool-ui/shared/media/sanitize-href.ts b/surfsense_web/components/tool-ui/shared/media/sanitize-href.ts
index de7ced700..19b18db28 100644
--- a/surfsense_web/components/tool-ui/shared/media/sanitize-href.ts
+++ b/surfsense_web/components/tool-ui/shared/media/sanitize-href.ts
@@ -1,28 +1,28 @@
export function sanitizeHref(href?: string): string | undefined {
- if (!href) return undefined;
- const candidate = href.trim();
- if (!candidate) return undefined;
+ if (!href) return undefined;
+ const candidate = href.trim();
+ if (!candidate) return undefined;
- if (
- candidate.startsWith("/") ||
- candidate.startsWith("./") ||
- candidate.startsWith("../") ||
- candidate.startsWith("?") ||
- candidate.startsWith("#")
- ) {
- if (candidate.startsWith("//")) return undefined;
- // eslint-disable-next-line no-control-regex -- intentionally matching control characters
- if (/[\u0000-\u001F\u007F]/.test(candidate)) return undefined;
- return candidate;
- }
+ if (
+ candidate.startsWith("/") ||
+ candidate.startsWith("./") ||
+ candidate.startsWith("../") ||
+ candidate.startsWith("?") ||
+ candidate.startsWith("#")
+ ) {
+ if (candidate.startsWith("//")) return undefined;
+ // eslint-disable-next-line no-control-regex -- intentionally matching control characters
+ if (/[\u0000-\u001F\u007F]/.test(candidate)) return undefined;
+ return candidate;
+ }
- try {
- const url = new URL(candidate);
- if (url.protocol === "http:" || url.protocol === "https:") {
- return url.toString();
- }
- } catch {
- return undefined;
- }
- return undefined;
+ try {
+ const url = new URL(candidate);
+ if (url.protocol === "http:" || url.protocol === "https:") {
+ return url.toString();
+ }
+ } catch {
+ return undefined;
+ }
+ return undefined;
}
diff --git a/surfsense_web/components/tool-ui/shared/schema.ts b/surfsense_web/components/tool-ui/shared/schema.ts
index b19f4a3b7..01d5ab1f7 100644
--- a/surfsense_web/components/tool-ui/shared/schema.ts
+++ b/surfsense_web/components/tool-ui/shared/schema.ts
@@ -1,5 +1,5 @@
-import { z } from "zod";
import type { ReactNode } from "react";
+import { z } from "zod";
/**
* Tool UI conventions:
@@ -30,21 +30,16 @@ export type ToolUIId = z.infer;
* Primary role of a Tool UI surface in a chat context.
*/
export const ToolUIRoleSchema = z.enum([
- "information",
- "decision",
- "control",
- "state",
- "composite",
+ "information",
+ "decision",
+ "control",
+ "state",
+ "composite",
]);
export type ToolUIRole = z.infer;
-export const ToolUIReceiptOutcomeSchema = z.enum([
- "success",
- "partial",
- "failed",
- "cancelled",
-]);
+export const ToolUIReceiptOutcomeSchema = z.enum(["success", "partial", "failed", "cancelled"]);
export type ToolUIReceiptOutcome = z.infer;
@@ -52,10 +47,10 @@ export type ToolUIReceiptOutcome = z.infer;
* Optional receipt metadata: a durable summary of an outcome.
*/
export const ToolUIReceiptSchema = z.object({
- outcome: ToolUIReceiptOutcomeSchema,
- summary: z.string().min(1),
- identifiers: z.record(z.string(), z.string()).optional(),
- at: z.string().datetime(),
+ outcome: ToolUIReceiptOutcomeSchema,
+ summary: z.string().min(1),
+ identifiers: z.record(z.string(), z.string()).optional(),
+ at: z.string().datetime(),
});
export type ToolUIReceipt = z.infer;
@@ -64,30 +59,28 @@ export type ToolUIReceipt = z.infer;
* Base schema for Tool UI payloads (id + optional role/receipt).
*/
export const ToolUISurfaceSchema = z.object({
- id: ToolUIIdSchema,
- role: ToolUIRoleSchema.optional(),
- receipt: ToolUIReceiptSchema.optional(),
+ id: ToolUIIdSchema,
+ role: ToolUIRoleSchema.optional(),
+ receipt: ToolUIReceiptSchema.optional(),
});
export type ToolUISurface = z.infer;
export const ActionSchema = z.object({
- id: z.string().min(1),
- label: z.string().min(1),
- /**
- * Canonical narration the assistant can use after this action is taken.
- *
- * Example: "I exported the table as CSV." / "I opened the link in a new tab."
- */
- sentence: z.string().optional(),
- confirmLabel: z.string().optional(),
- variant: z
- .enum(["default", "destructive", "secondary", "ghost", "outline"])
- .optional(),
- icon: z.custom().optional(),
- loading: z.boolean().optional(),
- disabled: z.boolean().optional(),
- shortcut: z.string().optional(),
+ id: z.string().min(1),
+ label: z.string().min(1),
+ /**
+ * Canonical narration the assistant can use after this action is taken.
+ *
+ * Example: "I exported the table as CSV." / "I opened the link in a new tab."
+ */
+ sentence: z.string().optional(),
+ confirmLabel: z.string().optional(),
+ variant: z.enum(["default", "destructive", "secondary", "ghost", "outline"]).optional(),
+ icon: z.custom().optional(),
+ loading: z.boolean().optional(),
+ disabled: z.boolean().optional(),
+ shortcut: z.string().optional(),
});
export type Action = z.infer;
@@ -95,65 +88,62 @@ export type LocalAction = Action;
export type DecisionAction = Action;
export const DecisionResultSchema = z.object({
- kind: z.literal("decision"),
- version: z.literal(1),
- decisionId: z.string().min(1),
- actionId: z.string().min(1),
- actionLabel: z.string().min(1),
- at: z.string().datetime(),
- payload: z.record(z.string(), z.unknown()).optional(),
+ kind: z.literal("decision"),
+ version: z.literal(1),
+ decisionId: z.string().min(1),
+ actionId: z.string().min(1),
+ actionLabel: z.string().min(1),
+ at: z.string().datetime(),
+ payload: z.record(z.string(), z.unknown()).optional(),
});
-export type DecisionResult<
- TPayload extends Record = Record,
-> = Omit, "payload"> & {
- payload?: TPayload;
-};
+export type DecisionResult = Record> =
+ Omit, "payload"> & {
+ payload?: TPayload;
+ };
export function createDecisionResult<
- TPayload extends Record = Record,
+ TPayload extends Record = Record,
>(args: {
- decisionId: string;
- action: { id: string; label: string };
- payload?: TPayload;
+ decisionId: string;
+ action: { id: string; label: string };
+ payload?: TPayload;
}): DecisionResult {
- return {
- kind: "decision",
- version: 1,
- decisionId: args.decisionId,
- actionId: args.action.id,
- actionLabel: args.action.label,
- at: new Date().toISOString(),
- payload: args.payload,
- };
+ return {
+ kind: "decision",
+ version: 1,
+ decisionId: args.decisionId,
+ actionId: args.action.id,
+ actionLabel: args.action.label,
+ at: new Date().toISOString(),
+ payload: args.payload,
+ };
}
export const ActionButtonsPropsSchema = z.object({
- actions: z.array(ActionSchema).min(1),
- align: z.enum(["left", "center", "right"]).optional(),
- confirmTimeout: z.number().positive().optional(),
- className: z.string().optional(),
+ actions: z.array(ActionSchema).min(1),
+ align: z.enum(["left", "center", "right"]).optional(),
+ confirmTimeout: z.number().positive().optional(),
+ className: z.string().optional(),
});
export const SerializableActionSchema = ActionSchema.omit({ icon: true });
export const SerializableActionsSchema = ActionButtonsPropsSchema.extend({
- actions: z.array(SerializableActionSchema),
+ actions: z.array(SerializableActionSchema),
}).omit({ className: true });
export interface ActionsConfig {
- items: Action[];
- align?: "left" | "center" | "right";
- confirmTimeout?: number;
+ items: Action[];
+ align?: "left" | "center" | "right";
+ confirmTimeout?: number;
}
export const SerializableActionsConfigSchema = z.object({
- items: z.array(SerializableActionSchema).min(1),
- align: z.enum(["left", "center", "right"]).optional(),
- confirmTimeout: z.number().positive().optional(),
+ items: z.array(SerializableActionSchema).min(1),
+ align: z.enum(["left", "center", "right"]).optional(),
+ confirmTimeout: z.number().positive().optional(),
});
-export type SerializableActionsConfig = z.infer<
- typeof SerializableActionsConfigSchema
->;
+export type SerializableActionsConfig = z.infer;
export type SerializableAction = z.infer;
diff --git a/surfsense_web/lib/apis/prompts-api.service.ts b/surfsense_web/lib/apis/prompts-api.service.ts
index 5c445c02a..307a9f597 100644
--- a/surfsense_web/lib/apis/prompts-api.service.ts
+++ b/surfsense_web/lib/apis/prompts-api.service.ts
@@ -4,8 +4,8 @@ import {
promptCreateRequest,
promptDeleteResponse,
promptRead,
- promptUpdateRequest,
promptsListResponse,
+ promptUpdateRequest,
} from "@/contracts/types/prompts.types";
import { ValidationError } from "@/lib/error";
import { baseApiService } from "./base-api.service";
diff --git a/surfsense_web/lib/chat/streaming-state.ts b/surfsense_web/lib/chat/streaming-state.ts
index 7894c8115..2f612a552 100644
--- a/surfsense_web/lib/chat/streaming-state.ts
+++ b/surfsense_web/lib/chat/streaming-state.ts
@@ -27,10 +27,7 @@ export interface ContentPartsState {
toolCallIndices: Map;
}
-function areThinkingStepsEqual(
- current: ThinkingStepData[],
- next: ThinkingStepData[]
-): boolean {
+function areThinkingStepsEqual(current: ThinkingStepData[], next: ThinkingStepData[]): boolean {
if (current.length !== next.length) return false;
for (let i = 0; i < current.length; i += 1) {