diff --git a/docs/chinese-llm-setup.md b/docs/chinese-llm-setup.md
index 37042aa2f..1fb0ce2a1 100644
--- a/docs/chinese-llm-setup.md
+++ b/docs/chinese-llm-setup.md
@@ -24,7 +24,7 @@ SurfSense 现已支持以下国产 LLM:
1. 登录 SurfSense Dashboard
2. 进入 **Settings** → **API Keys** (或 **LLM Configurations**)
-3. 点击 **Add New Configuration**
+3. 点击 **Add LLM Model**
4. 从 **Provider** 下拉菜单中选择你的国产 LLM 提供商
5. 填写必填字段(见下方各提供商详细配置)
6. 点击 **Save**
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 @@ async def create_onedrive_file(
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 @@ async def create_onedrive_file(
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 @@ async def create_onedrive_file(
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 @@ async def create_onedrive_file(
}
)
- 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 @@ async def create_onedrive_file(
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 @@ async def create_onedrive_file(
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 @@ async def create_onedrive_file(
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 @@ async def delete_onedrive_file(
- 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 @@ async def delete_onedrive_file(
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 @@ async def delete_onedrive_file(
}
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 @@ async def delete_onedrive_file(
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 @@ async def delete_onedrive_file(
}
)
- 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 @@ async def delete_onedrive_file(
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 @@ async def delete_onedrive_file(
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 @@ async def delete_onedrive_file(
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 @@ async def _get_valid_token(self) -> str:
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 @@ async def download_file(self, item_id: str) -> tuple[bytes | None, str | None]:
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 @@
"""
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 .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 .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 @@ async def sync_after_create(
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 @@ async def sync_after_create(
)
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 c1ca089d0..4b37cb69e 100644
--- a/surfsense_backend/app/tasks/chat/stream_new_chat.py
+++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py
@@ -1075,6 +1075,37 @@ def complete_current_step() -> str | None:
"thread_id": thread_id_str,
},
)
+ elif tool_name == "web_search":
+ 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* ",
+ xml,
+ ):
+ title, url = m.group(1).strip(), m.group(2).strip()
+ if url.startswith("http") and url not in citations:
+ citations[url] = {"title": title}
+ for m in re.finditer(
+ r" ",
+ xml,
+ ):
+ chunk_url, content = m.group(1).strip(), m.group(2).strip()
+ if (
+ chunk_url.startswith("http")
+ and chunk_url in citations
+ and content
+ ):
+ citations[chunk_url]["snippet"] = (
+ content[:200] + "…" if len(content) > 200 else content
+ )
+ yield streaming_service.format_tool_output_available(
+ tool_call_id,
+ {"status": "completed", "citations": citations},
+ )
else:
yield streaming_service.format_tool_output_available(
tool_call_id,
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 @@
# Helpers
# ---------------------------------------------------------------------------
+
async def _should_skip_file(
session: AsyncSession,
file: dict,
@@ -186,9 +187,13 @@ async def _download_one(file: dict) -> ConnectorDocument | None:
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_one(file: dict) -> ConnectorDocument | None:
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 _get_llm(s):
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 @@
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(side_effect=None, return_value=None):
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 _slow_extract(client, file):
# 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]/client-layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx
index 25e4e990b..1715e525f 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx
@@ -183,6 +183,10 @@ export function DashboardClientLayout({
);
}
+ if (isOnboardingPage) {
+ return <>{children}>;
+ }
+
return (
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 68d971fc4..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
@@ -986,9 +990,10 @@ export function DocumentsTableShell({
handleDeleteFromMenu();
}}
disabled={isDeleting}
- className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
- {isDeleting ? : "Delete"}
+ Delete
+ {isDeleting && }
@@ -1104,9 +1109,10 @@ export function DocumentsTableShell({
handleBulkDelete();
}}
disabled={isBulkDeleting}
- className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
- {isBulkDeleting ? : "Delete"}
+ Delete
+ {isBulkDeleting && }
diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx
index a8b85e20b..5b7451c61 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx
@@ -24,7 +24,7 @@ import {
} from "@/components/ui/dropdown-menu";
import type { Document } from "./types";
-const EDITABLE_DOCUMENT_TYPES = ["NOTE"] as const;
+const EDITABLE_DOCUMENT_TYPES = ["FILE", "NOTE"] as const;
// SURFSENSE_DOCS are system-managed and cannot be deleted
const NON_DELETABLE_DOCUMENT_TYPES = ["SURFSENSE_DOCS"] as const;
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 9809c9b2e..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
@@ -33,7 +33,7 @@ import { closeReportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { type AgentCreatedDocument, agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms";
import { closeEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { membersAtom } from "@/atoms/members/members-query.atoms";
-import { updateChatTabTitleAtom } from "@/atoms/tabs/tabs.atom";
+import { removeChatTabAtom, updateChatTabTitleAtom } from "@/atoms/tabs/tabs.atom";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { ThinkingStepsDataUI } from "@/components/assistant-ui/thinking-steps";
import { Thread } from "@/components/assistant-ui/thread";
@@ -70,6 +70,7 @@ import {
getThreadMessages,
type ThreadRecord,
} from "@/lib/chat/thread-persistence";
+import { NotFoundError } from "@/lib/error";
import {
trackChatCreated,
trackChatError,
@@ -131,6 +132,7 @@ function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] {
* Tools that should render custom UI in the chat.
*/
const TOOLS_WITH_UI = new Set([
+ "web_search",
"generate_podcast",
"generate_report",
"generate_video_presentation",
@@ -194,6 +196,7 @@ export default function NewChatPage() {
const closeReportPanel = useSetAtom(closeReportPanelAtom);
const closeEditorPanel = useSetAtom(closeEditorPanelAtom);
const updateChatTabTitle = useSetAtom(updateChatTabTitleAtom);
+ const removeChatTab = useSetAtom(removeChatTabAtom);
const setAgentCreatedDocuments = useSetAtom(agentCreatedDocumentsAtom);
// Get current user for author info in shared chats
@@ -323,6 +326,14 @@ export default function NewChatPage() {
// This improves UX (instant load) and avoids orphan threads
} catch (error) {
console.error("[NewChatPage] Failed to initialize thread:", error);
+ if (urlChatId > 0 && error instanceof NotFoundError) {
+ removeChatTab(urlChatId);
+ if (typeof window !== "undefined") {
+ window.history.replaceState(null, "", `/dashboard/${searchSpaceId}/new-chat`);
+ }
+ toast.error("This chat was deleted.");
+ return;
+ }
// Keep threadId as null - don't use Date.now() as it creates an invalid ID
// that will cause 404 errors on subsequent API calls
setThreadId(null);
@@ -338,12 +349,14 @@ export default function NewChatPage() {
setSidebarDocuments,
closeReportPanel,
closeEditorPanel,
+ removeChatTab,
+ searchSpaceId,
]);
// Initialize on mount, and re-init when switching search spaces (even if urlChatId is the same)
useEffect(() => {
initializeThread();
- }, [initializeThread, searchSpaceId]);
+ }, [initializeThread]);
// Prefetch document titles for @ mention picker
// Runs when user lands on page so data is ready when they type @
@@ -483,18 +496,17 @@ export default function NewChatPage() {
// Add user message to state
const userMsgId = `msg-user-${Date.now()}`;
- // Include author metadata for shared chats
- const authorMetadata =
- currentThread?.visibility === "SEARCH_SPACE" && currentUser
- ? {
- custom: {
- author: {
- displayName: currentUser.display_name ?? null,
- avatarUrl: currentUser.avatar_url ?? null,
- },
+ // Always include author metadata so the UI layer can decide visibility
+ const authorMetadata = currentUser
+ ? {
+ custom: {
+ author: {
+ displayName: currentUser.display_name ?? null,
+ avatarUrl: currentUser.avatar_url ?? null,
},
- }
- : undefined;
+ },
+ }
+ : undefined;
const userMessage: ThreadMessageLike = {
id: userMsgId,
@@ -654,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));
@@ -882,7 +894,6 @@ export default function NewChatPage() {
setMessageDocumentsMap,
setAgentCreatedDocuments,
queryClient,
- currentThread,
currentUser,
disabledTools,
updateChatTabTitle,
@@ -1001,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
@@ -1013,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-input-available":
- if (toolCallIndices.has(parsed.toolCallId)) {
+ case "tool-output-available":
updateToolCall(contentPartsState, parsed.toolCallId, {
- args: parsed.input || {},
+ result: parsed.output,
});
- } 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;
+ 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 "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-interrupt-request": {
+ case "data-interrupt-request": {
const interruptData = parsed.data as Record;
const actionRequests = (interruptData.action_requests ?? []) as Array<{
name: string;
@@ -1319,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
@@ -1331,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();
@@ -1536,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 b188d7c8f..4dba3bbb6 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx
@@ -1,7 +1,6 @@
"use client";
-import { useAtomValue, useSetAtom } from "jotai";
-import { motion } from "motion/react";
+import { useAtomValue } from "jotai";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
@@ -13,19 +12,17 @@ import {
globalNewLLMConfigsAtom,
llmPreferencesAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
-import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { Logo } from "@/components/Logo";
import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
+import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { getBearerToken, redirectToLogin } from "@/lib/auth-utils";
export default function OnboardPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = Number(params.search_space_id);
- const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
-
// Queries
const {
data: globalConfigs = [],
@@ -62,14 +59,12 @@ export default function OnboardPage() {
preferences.document_summary_llm_id !== null &&
preferences.document_summary_llm_id !== undefined;
- // If onboarding is already complete, redirect immediately
useEffect(() => {
if (!preferencesLoading && isOnboardingComplete) {
router.push(`/dashboard/${searchSpaceId}/new-chat`);
}
}, [preferencesLoading, isOnboardingComplete, router, searchSpaceId]);
- // Auto-configure if global configs are available
useEffect(() => {
const autoConfigureWithGlobal = async () => {
if (hasAttemptedAutoConfig.current) return;
@@ -77,7 +72,6 @@ export default function OnboardPage() {
if (!globalConfigsLoaded) return;
if (isOnboardingComplete) return;
- // Only auto-configure if we have global configs
if (globalConfigs.length > 0) {
hasAttemptedAutoConfig.current = true;
setIsAutoConfiguring(true);
@@ -97,7 +91,6 @@ export default function OnboardPage() {
description: `Using ${firstGlobalConfig.name}. You can customize this later in Settings.`,
});
- // Redirect to new-chat
router.push(`/dashboard/${searchSpaceId}/new-chat`);
} catch (error) {
console.error("Auto-configuration failed:", error);
@@ -119,13 +112,10 @@ export default function OnboardPage() {
router,
]);
- // Handle form submission
const handleSubmit = async (formData: LLMConfigFormData) => {
try {
- // Create the config
const newConfig = await createConfig(formData);
- // Auto-assign to all roles
await updatePreferences({
search_space_id: searchSpaceId,
data: {
@@ -138,7 +128,6 @@ export default function OnboardPage() {
description: "Redirecting to chat...",
});
- // Redirect to new-chat
router.push(`/dashboard/${searchSpaceId}/new-chat`);
} catch (error) {
console.error("Failed to create config:", error);
@@ -150,124 +139,59 @@ export default function OnboardPage() {
const isSubmitting = isCreating || isUpdatingPreferences;
- // Loading state
- if (globalConfigsLoading || preferencesLoading || isAutoConfiguring) {
- return (
-
-
-
-
-
- {isAutoConfiguring ? "Setting up your AI..." : "Loading..."}
-
-
- {isAutoConfiguring
- ? "Auto-configuring with available settings"
- : "Please wait while we check your configuration"}
-
-
-
- {[0, 1, 2].map((i) => (
-
- ))}
-
-
-
- );
+ const isLoading = globalConfigsLoading || preferencesLoading || isAutoConfiguring;
+ useGlobalLoadingEffect(isLoading);
+
+ if (isLoading) {
+ return null;
}
- // If global configs exist but auto-config failed, show simple message
if (globalConfigs.length > 0 && !isAutoConfiguring) {
- return null; // Will redirect via useEffect
+ return null;
}
- // No global configs - show the config form
return (
-
-
-
- {/* Header */}
-
-
-
-
-
-
-
Configure Your AI
-
- Add your LLM provider to get started with SurfSense
-
-
+
+
+ {/* Header */}
+
+
+
+
Configure Your AI
+
+ Add your LLM provider to get started with SurfSense
+
-
- {/* Config Form */}
-
-
-
- LLM Configuration
-
-
-
-
-
-
-
- {/* Footer note */}
-
+
+ {/* Form card */}
+
+
+
+
+ {/* Footer */}
+
+
- You can add more configurations and customize settings anytime in{" "}
- setSearchSpaceSettingsDialog({ open: true, initialTab: "general" })}
- className="text-violet-500 hover:underline"
- >
- Settings
-
-
-
+ Start Using SurfSense
+ {isSubmitting && }
+
+
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 38ccafa94..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
@@ -165,8 +173,9 @@ export function PromptsContent() {
Cancel
-
- {isSaving ? : editingId ? "Update" : "Create"}
+
+ {editingId ? "Update" : "Create"}
+ {isSaving && }
diff --git a/surfsense_web/atoms/image-gen-config/image-gen-config-mutation.atoms.ts b/surfsense_web/atoms/image-gen-config/image-gen-config-mutation.atoms.ts
index dbaf441d0..362c3a690 100644
--- a/surfsense_web/atoms/image-gen-config/image-gen-config-mutation.atoms.ts
+++ b/surfsense_web/atoms/image-gen-config/image-gen-config-mutation.atoms.ts
@@ -2,6 +2,8 @@ import { atomWithMutation } from "jotai-tanstack-query";
import { toast } from "sonner";
import type {
CreateImageGenConfigRequest,
+ CreateImageGenConfigResponse,
+ DeleteImageGenConfigResponse,
GetImageGenConfigsResponse,
UpdateImageGenConfigRequest,
UpdateImageGenConfigResponse,
@@ -23,14 +25,14 @@ export const createImageGenConfigMutationAtom = atomWithMutation((get) => {
mutationFn: async (request: CreateImageGenConfigRequest) => {
return imageGenConfigApiService.createConfig(request);
},
- onSuccess: () => {
- toast.success("Image model configuration created");
+ onSuccess: (_: CreateImageGenConfigResponse, request: CreateImageGenConfigRequest) => {
+ toast.success(`${request.name} created`);
queryClient.invalidateQueries({
queryKey: cacheKeys.imageGenConfigs.all(Number(searchSpaceId)),
});
},
onError: (error: Error) => {
- toast.error(error.message || "Failed to create image model configuration");
+ toast.error(error.message || "Failed to create image model");
},
};
});
@@ -48,7 +50,7 @@ export const updateImageGenConfigMutationAtom = atomWithMutation((get) => {
return imageGenConfigApiService.updateConfig(request);
},
onSuccess: (_: UpdateImageGenConfigResponse, request: UpdateImageGenConfigRequest) => {
- toast.success("Image model configuration updated");
+ toast.success(`${request.data.name ?? "Configuration"} updated`);
queryClient.invalidateQueries({
queryKey: cacheKeys.imageGenConfigs.all(Number(searchSpaceId)),
});
@@ -57,7 +59,7 @@ export const updateImageGenConfigMutationAtom = atomWithMutation((get) => {
});
},
onError: (error: Error) => {
- toast.error(error.message || "Failed to update image model configuration");
+ toast.error(error.message || "Failed to update image model");
},
};
});
@@ -71,21 +73,21 @@ export const deleteImageGenConfigMutationAtom = atomWithMutation((get) => {
return {
mutationKey: ["image-gen-configs", "delete"],
enabled: !!searchSpaceId,
- mutationFn: async (id: number) => {
- return imageGenConfigApiService.deleteConfig(id);
+ mutationFn: async (request: { id: number; name: string }) => {
+ return imageGenConfigApiService.deleteConfig(request.id);
},
- onSuccess: (_, id: number) => {
- toast.success("Image model configuration deleted");
+ onSuccess: (_: DeleteImageGenConfigResponse, request: { id: number; name: string }) => {
+ toast.success(`${request.name} deleted`);
queryClient.setQueryData(
cacheKeys.imageGenConfigs.all(Number(searchSpaceId)),
(oldData: GetImageGenConfigsResponse | undefined) => {
if (!oldData) return oldData;
- return oldData.filter((config) => config.id !== id);
+ return oldData.filter((config) => config.id !== request.id);
}
);
},
onError: (error: Error) => {
- toast.error(error.message || "Failed to delete image model configuration");
+ toast.error(error.message || "Failed to delete image model");
},
};
});
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 8f81b7475..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
@@ -2,7 +2,9 @@ import { atomWithMutation } from "jotai-tanstack-query";
import { toast } from "sonner";
import type {
CreateNewLLMConfigRequest,
+ CreateNewLLMConfigResponse,
DeleteNewLLMConfigRequest,
+ DeleteNewLLMConfigResponse,
GetNewLLMConfigsResponse,
UpdateLLMPreferencesRequest,
UpdateNewLLMConfigRequest,
@@ -25,14 +27,14 @@ export const createNewLLMConfigMutationAtom = atomWithMutation((get) => {
mutationFn: async (request: CreateNewLLMConfigRequest) => {
return newLLMConfigApiService.createConfig(request);
},
- onSuccess: () => {
- toast.success("Configuration created successfully");
+ onSuccess: (_: CreateNewLLMConfigResponse, request: CreateNewLLMConfigRequest) => {
+ toast.success(`${request.name} created`);
queryClient.invalidateQueries({
queryKey: cacheKeys.newLLMConfigs.all(Number(searchSpaceId)),
});
},
onError: (error: Error) => {
- toast.error(error.message || "Failed to create configuration");
+ toast.error(error.message || "Failed to create LLM model");
},
};
});
@@ -50,7 +52,7 @@ export const updateNewLLMConfigMutationAtom = atomWithMutation((get) => {
return newLLMConfigApiService.updateConfig(request);
},
onSuccess: (_: UpdateNewLLMConfigResponse, request: UpdateNewLLMConfigRequest) => {
- toast.success("Configuration updated successfully");
+ toast.success(`${request.data.name ?? "Configuration"} updated`);
queryClient.invalidateQueries({
queryKey: cacheKeys.newLLMConfigs.all(Number(searchSpaceId)),
});
@@ -59,7 +61,7 @@ export const updateNewLLMConfigMutationAtom = atomWithMutation((get) => {
});
},
onError: (error: Error) => {
- toast.error(error.message || "Failed to update configuration");
+ toast.error(error.message || "Failed to update");
},
};
});
@@ -73,11 +75,14 @@ export const deleteNewLLMConfigMutationAtom = atomWithMutation((get) => {
return {
mutationKey: ["new-llm-configs", "delete"],
enabled: !!searchSpaceId,
- mutationFn: async (request: DeleteNewLLMConfigRequest) => {
- return newLLMConfigApiService.deleteConfig(request);
+ mutationFn: async (request: DeleteNewLLMConfigRequest & { name: string }) => {
+ return newLLMConfigApiService.deleteConfig({ id: request.id });
},
- onSuccess: (_, request: DeleteNewLLMConfigRequest) => {
- toast.success("Configuration deleted successfully");
+ onSuccess: (
+ _: DeleteNewLLMConfigResponse,
+ request: DeleteNewLLMConfigRequest & { name: string }
+ ) => {
+ toast.success(`${request.name} deleted`);
queryClient.setQueryData(
cacheKeys.newLLMConfigs.all(Number(searchSpaceId)),
(oldData: GetNewLLMConfigsResponse | undefined) => {
@@ -87,7 +92,7 @@ export const deleteNewLLMConfigMutationAtom = atomWithMutation((get) => {
);
},
onError: (error: Error) => {
- toast.error(error.message || "Failed to delete configuration");
+ toast.error(error.message || "Failed to delete");
},
};
});
diff --git a/surfsense_web/atoms/tabs/tabs.atom.ts b/surfsense_web/atoms/tabs/tabs.atom.ts
index 7ba115a95..22cc5373a 100644
--- a/surfsense_web/atoms/tabs/tabs.atom.ts
+++ b/surfsense_web/atoms/tabs/tabs.atom.ts
@@ -33,6 +33,9 @@ const initialState: TabsState = {
activeTabId: "chat-new",
};
+// Prevent race conditions where route-sync recreates a just-deleted chat tab.
+const deletedChatIdsAtom = atom
>(new Set());
+
const sessionStorageAdapter = createJSONStorage(
() => (typeof window !== "undefined" ? sessionStorage : undefined) as Storage
);
@@ -71,6 +74,10 @@ export const syncChatTabAtom = atom(
set,
{ chatId, title, chatUrl }: { chatId: number | null; title?: string; chatUrl?: string }
) => {
+ if (chatId && get(deletedChatIdsAtom).has(chatId)) {
+ return;
+ }
+
const state = get(tabsStateAtom);
const tabId = makeChatTabId(chatId);
const existing = state.tabs.find((t) => t.id === tabId);
@@ -128,6 +135,19 @@ export const updateChatTabTitleAtom = atom(
(get, set, { chatId, title }: { chatId: number; title: string }) => {
const state = get(tabsStateAtom);
const tabId = makeChatTabId(chatId);
+ const hasExactTab = state.tabs.some((t) => t.id === tabId);
+
+ // During lazy thread creation, title updates can arrive before "chat-new"
+ // is swapped to chat-{id}. In that case, promote the active "chat-new" tab.
+ if (!hasExactTab && state.activeTabId === "chat-new") {
+ set(tabsStateAtom, {
+ ...state,
+ activeTabId: tabId,
+ tabs: state.tabs.map((t) => (t.id === "chat-new" ? { ...t, id: tabId, chatId, title } : t)),
+ });
+ return;
+ }
+
set(tabsStateAtom, {
...state,
tabs: state.tabs.map((t) => (t.id === tabId ? { ...t, title } : t)),
@@ -213,7 +233,39 @@ export const closeTabAtom = atom(null, (get, set, tabId: string) => {
return remaining.find((t) => t.id === newActiveId) ?? null;
});
+/** Remove a chat tab by chat ID (used when a chat is deleted). */
+export const removeChatTabAtom = atom(null, (get, set, chatId: number) => {
+ const state = get(tabsStateAtom);
+ const tabId = makeChatTabId(chatId);
+ const idx = state.tabs.findIndex((t) => t.id === tabId);
+ if (idx === -1) return null;
+
+ const deletedChatIds = get(deletedChatIdsAtom);
+ set(deletedChatIdsAtom, new Set([...deletedChatIds, chatId]));
+
+ const remaining = state.tabs.filter((t) => t.id !== tabId);
+
+ // Always keep at least one tab available.
+ if (remaining.length === 0) {
+ set(tabsStateAtom, {
+ tabs: [INITIAL_CHAT_TAB],
+ activeTabId: "chat-new",
+ });
+ return INITIAL_CHAT_TAB;
+ }
+
+ let newActiveId = state.activeTabId;
+ if (state.activeTabId === tabId) {
+ const newIdx = Math.min(idx, remaining.length - 1);
+ newActiveId = remaining[newIdx].id;
+ }
+
+ set(tabsStateAtom, { tabs: remaining, activeTabId: newActiveId });
+ return remaining.find((t) => t.id === newActiveId) ?? null;
+});
+
/** Reset tabs when switching search spaces. */
export const resetTabsAtom = atom(null, (_get, set) => {
set(tabsStateAtom, { ...initialState });
+ set(deletedChatIdsAtom, new Set());
});
diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx
index abd70e3f4..62407bbf7 100644
--- a/surfsense_web/components/assistant-ui/assistant-message.tsx
+++ b/surfsense_web/components/assistant-ui/assistant-message.tsx
@@ -7,16 +7,30 @@ 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,
+ ExternalLink,
+ Globe,
+ MessageSquare,
+ RefreshCwIcon,
+} from "lucide-react";
import type { FC } from "react";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { commentsEnabledAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
+import {
+ CitationMetadataProvider,
+ useAllCitationMetadata,
+} from "@/components/assistant-ui/citation-metadata-context";
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container";
import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet";
+import type { SerializableCitation } from "@/components/tool-ui/citation";
import {
CreateConfluencePageToolUI,
DeleteConfluencePageToolUI,
@@ -40,10 +54,6 @@ import {
CreateGoogleDriveFileToolUI,
DeleteGoogleDriveFileToolUI,
} from "@/components/tool-ui/google-drive";
-import {
- CreateOneDriveFileToolUI,
- DeleteOneDriveFileToolUI,
-} from "@/components/tool-ui/onedrive";
import {
CreateJiraIssueToolUI,
DeleteJiraIssueToolUI,
@@ -59,13 +69,159 @@ 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 {
+ openSafeNavigationHref,
+ resolveSafeNavigationHref,
+} from "@/components/tool-ui/shared/media";
import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory";
import { GenerateVideoPresentationToolUI } from "@/components/tool-ui/video-presentation";
+import { Drawer, DrawerContent, DrawerHandle, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
import { useComments } from "@/hooks/use-comments";
import { useMediaQuery } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils";
+function extractDomain(url: string): string | undefined {
+ try {
+ return new URL(url).hostname.replace(/^www\./, "");
+ } catch {
+ return undefined;
+ }
+}
+
+function useCitationsFromMetadata(): SerializableCitation[] {
+ const allCitations = useAllCitationMetadata();
+ return useMemo(() => {
+ const result: SerializableCitation[] = [];
+ for (const [url, meta] of allCitations) {
+ const domain = extractDomain(url);
+ result.push({
+ id: `url-cite-${url}`,
+ href: url,
+ title: meta.title,
+ snippet: meta.snippet,
+ domain,
+ favicon: domain ? `https://www.google.com/s2/favicons?domain=${domain}&sz=32` : undefined,
+ type: "webpage",
+ });
+ }
+ return result;
+ }, [allCitations]);
+}
+
+const MobileCitationDrawer: FC = () => {
+ const [open, setOpen] = useState(false);
+ const citations = useCitationsFromMetadata();
+
+ if (citations.length === 0) return null;
+
+ const maxIcons = 4;
+ const visible = citations.slice(0, maxIcons);
+ const remainingCount = Math.max(0, citations.length - maxIcons);
+
+ const handleNavigate = (citation: SerializableCitation) => {
+ const href = resolveSafeNavigationHref(citation.href);
+ if (href) openSafeNavigationHref(href);
+ };
+
+ return (
+ <>
+ 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"
+ )}
+ >
+
+ {visible.map((citation, index) => (
+
0 && "-ml-2"
+ )}
+ style={{ zIndex: maxIcons - index }}
+ >
+ {citation.favicon ? (
+ // biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain
+
+ ) : (
+
+ )}
+
+ ))}
+ {remainingCount > 0 && (
+
+
+ •••
+
+
+ )}
+
+
+ {citations.length} source{citations.length !== 1 && "s"}
+
+
+
+
+
+
+
+ Sources
+
+
+ {citations.map((citation) => (
+
handleNavigate(citation)}
+ className="group flex w-full items-center gap-2.5 rounded-md px-3 py-2.5 text-left transition-colors hover:bg-muted focus-visible:bg-muted focus-visible:outline-none"
+ >
+ {citation.favicon ? (
+ // biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain
+
+ ) : (
+
+ )}
+
+
+ {citation.title}
+
+
{citation.domain}
+
+
+
+ ))}
+
+
+
+ >
+ );
+};
+
export const MessageError: FC = () => {
return (
@@ -77,8 +233,10 @@ export const MessageError: FC = () => {
};
const AssistantMessageInner: FC = () => {
+ const isMobile = !useMediaQuery("(min-width: 768px)");
+
return (
- <>
+
{
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,
@@ -127,10 +286,16 @@ const AssistantMessageInner: FC = () => {
+ {isMobile && (
+
+
+
+ )}
+
- >
+
);
};
diff --git a/surfsense_web/components/assistant-ui/citation-metadata-context.tsx b/surfsense_web/components/assistant-ui/citation-metadata-context.tsx
new file mode 100644
index 000000000..0bf5dd946
--- /dev/null
+++ b/surfsense_web/components/assistant-ui/citation-metadata-context.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import { useAuiState } from "@assistant-ui/react";
+import { createContext, type FC, type ReactNode, useContext, useMemo } from "react";
+
+export interface CitationMeta {
+ title: string;
+ snippet?: string;
+}
+
+type CitationMetadataMap = ReadonlyMap;
+
+const CitationMetadataContext = createContext(new Map());
+
+interface ToolCallResult {
+ status?: string;
+ citations?: Record;
+}
+
+interface MessageContent {
+ type: string;
+ toolName?: string;
+ result?: unknown;
+}
+
+export const CitationMetadataProvider: FC<{ children: ReactNode }> = ({ children }) => {
+ const content = useAuiState(
+ ({ message }) => (message as { content?: MessageContent[] })?.content
+ );
+
+ const metadataMap = useMemo(() => {
+ if (!content || !Array.isArray(content)) return new Map();
+
+ const merged = new Map();
+
+ for (const part of content) {
+ if (part.type !== "tool-call" || part.toolName !== "web_search" || !part.result) {
+ continue;
+ }
+
+ const result = part.result as ToolCallResult;
+ const citations = result.citations;
+ if (!citations || typeof citations !== "object") continue;
+
+ for (const [url, meta] of Object.entries(citations)) {
+ if (url.startsWith("http") && meta.title && !merged.has(url)) {
+ merged.set(url, { title: meta.title, snippet: meta.snippet });
+ }
+ }
+ }
+
+ return merged;
+ }, [content]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export function useCitationMetadata(url: string): CitationMeta | undefined {
+ const map = useContext(CitationMetadataContext);
+ return map.get(url);
+}
+
+export function useAllCitationMetadata(): CitationMetadataMap {
+ return useContext(CitationMetadataContext);
+}
diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx
index f1cf5ee4d..5a35016f5 100644
--- a/surfsense_web/components/assistant-ui/connector-popup.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup.tsx
@@ -1,7 +1,7 @@
"use client";
import { useAtomValue, useSetAtom } from "jotai";
-import { AlertTriangle, Cable, Settings } from "lucide-react";
+import { AlertTriangle, Settings } from "lucide-react";
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
@@ -12,17 +12,14 @@ import {
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
-import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
-import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { useConnectorsSync } from "@/hooks/use-connectors-sync";
import { PICKER_CLOSE_EVENT, PICKER_OPEN_EVENT } from "@/hooks/use-google-picker";
import { useZeroDocumentTypeCounts } from "@/hooks/use-zero-document-type-counts";
-import { cn } from "@/lib/utils";
import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header";
import { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-view";
import { ConnectorEditView } from "./connector-popup/connector-configs/views/connector-edit-view";
@@ -47,7 +44,7 @@ interface ConnectorIndicatorProps {
}
export const ConnectorIndicator = forwardRef(
- ({ showTrigger = true }, ref) => {
+ (_props, ref) => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
useAtomValue(currentUserAtom);
@@ -74,8 +71,6 @@ export const ConnectorIndicator = forwardRef count > 0)
@@ -205,41 +198,6 @@ export const ConnectorIndicator = forwardRef
- {showTrigger && (
- handleOpenChange(true)}
- >
- {isLoading ? (
-
- ) : (
- <>
-
- {activeConnectorsCount > 0 && (
-
- {activeConnectorsCount > 99 ? "99+" : activeConnectorsCount}
-
- )}
- >
- )}
-
- )}
-
{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/components/connector-card.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx
index 4119b74cd..d24057b1c 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx
@@ -143,7 +143,7 @@ export const ConnectorCard: FC = ({
size="sm"
variant={isConnected ? "secondary" : "default"}
className={cn(
- "h-8 text-[11px] px-3 rounded-lg shrink-0 font-medium",
+ "relative h-8 text-[11px] px-3 rounded-lg shrink-0 font-medium items-center justify-center",
isConnected &&
"bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80",
!isConnected && "shadow-xs"
@@ -151,19 +151,18 @@ export const ConnectorCard: FC = ({
onClick={isConnected ? onManage : onConnect}
disabled={isConnecting || !isEnabled}
>
- {isConnecting ? (
-
- ) : !isEnabled ? (
- "Unavailable"
- ) : isConnected ? (
- "Manage"
- ) : id === "youtube-crawler" ? (
- "Add"
- ) : connectorType ? (
- "Connect"
- ) : (
- "Add"
- )}
+
+ {!isEnabled
+ ? "Unavailable"
+ : isConnected
+ ? "Manage"
+ : id === "youtube-crawler"
+ ? "Add"
+ : connectorType
+ ? "Connect"
+ : "Add"}
+
+ {isConnecting && }
);
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-citation.tsx b/surfsense_web/components/assistant-ui/inline-citation.tsx
index 1c9fa6ba4..52c679c23 100644
--- a/surfsense_web/components/assistant-ui/inline-citation.tsx
+++ b/surfsense_web/components/assistant-ui/inline-citation.tsx
@@ -1,9 +1,10 @@
"use client";
-import { ExternalLink } from "lucide-react";
import type { FC } from "react";
import { useState } from "react";
+import { useCitationMetadata } from "@/components/assistant-ui/citation-metadata-context";
import { SourceDetailPanel } from "@/components/new-chat/source-detail-panel";
+import { Citation } from "@/components/tool-ui/citation";
interface InlineCitationProps {
chunkId: number;
@@ -55,21 +56,23 @@ interface UrlCitationProps {
/**
* Inline citation for live web search results (URL-based chunk IDs).
- * Renders a clickable badge showing the source domain that opens the URL in a new tab.
+ * Renders a compact chip with favicon + domain and a hover popover showing the
+ * page title and snippet (extracted deterministically from web_search tool results).
*/
export const UrlCitation: FC = ({ url }) => {
const domain = extractDomain(url);
+ const meta = useCitationMetadata(url);
return (
-
-
- {domain}
-
+ title={meta?.title || domain}
+ snippet={meta?.snippet}
+ domain={domain}
+ favicon={`https://www.google.com/s2/favicons?domain=${domain}&sz=32`}
+ variant="inline"
+ type="webpage"
+ />
);
};
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/thinking-steps.tsx b/surfsense_web/components/assistant-ui/thinking-steps.tsx
index 900fc7b09..2c47b0006 100644
--- a/surfsense_web/components/assistant-ui/thinking-steps.tsx
+++ b/surfsense_web/components/assistant-ui/thinking-steps.tsx
@@ -20,8 +20,6 @@ export const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?:
steps,
isThreadRunning = true,
}) => {
- const [isOpen, setIsOpen] = useState(true);
-
const getEffectiveStatus = useCallback(
(step: ThinkingStep): "pending" | "in_progress" | "completed" => {
if (step.status === "in_progress" && !isThreadRunning) {
@@ -38,12 +36,18 @@ export const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?:
!isThreadRunning &&
steps.every((s) => getEffectiveStatus(s) === "completed");
const isProcessing = isThreadRunning && !allCompleted;
+ const [isOpen, setIsOpen] = useState(() => isProcessing);
useEffect(() => {
+ if (isProcessing) {
+ setIsOpen(true);
+ return;
+ }
+
if (allCompleted) {
setIsOpen(false);
}
- }, [allCompleted]);
+ }, [allCompleted, isProcessing]);
if (steps.length === 0) return null;
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/assistant-ui/user-message.tsx b/surfsense_web/components/assistant-ui/user-message.tsx
index 74461e760..34945c472 100644
--- a/surfsense_web/components/assistant-ui/user-message.tsx
+++ b/surfsense_web/components/assistant-ui/user-message.tsx
@@ -3,6 +3,7 @@ import { useAtomValue } from "jotai";
import { CheckIcon, CopyIcon, FileText, Pen } from "lucide-react";
import Image from "next/image";
import { type FC, useState } from "react";
+import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
@@ -51,6 +52,8 @@ export const UserMessage: FC = () => {
const mentionedDocs = messageId ? messageDocumentsMap[messageId] : undefined;
const metadata = useAuiState(({ message }) => message?.metadata);
const author = metadata?.custom?.author as AuthorMetadata | undefined;
+ const isSharedChat = useAtomValue(currentThreadAtom).visibility === "SEARCH_SPACE";
+ const showAvatar = isSharedChat && !!author;
return (
{
- {author && (
+ {showAvatar && (
diff --git a/surfsense_web/components/documents/DocumentNode.tsx b/surfsense_web/components/documents/DocumentNode.tsx
index a562703e8..79cf2233b 100644
--- a/surfsense_web/components/documents/DocumentNode.tsx
+++ b/surfsense_web/components/documents/DocumentNode.tsx
@@ -40,6 +40,8 @@ import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { cn } from "@/lib/utils";
import { DND_TYPES } from "./FolderNode";
+const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]);
+
export interface DocumentNodeDoc {
id: number;
title: string;
@@ -78,7 +80,9 @@ export const DocumentNode = React.memo(function DocumentNode({
const statusState = doc.status?.state ?? "ready";
const isSelectable = statusState !== "pending" && statusState !== "processing";
const isEditable =
- doc.document_type === "NOTE" && statusState !== "pending" && statusState !== "processing";
+ EDITABLE_DOCUMENT_TYPES.has(doc.document_type) &&
+ statusState !== "pending" &&
+ statusState !== "processing";
const handleCheckChange = useCallback(() => {
if (isSelectable) {
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 f2f66eb48..308ad158b 100644
--- a/surfsense_web/components/editor-panel/editor-panel.tsx
+++ b/surfsense_web/components/editor-panel/editor-panel.tsx
@@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
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";
@@ -18,6 +19,8 @@ interface EditorContent {
source_markdown: string;
}
+const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]);
+
function EditorPanelSkeleton() {
return (
@@ -165,12 +168,16 @@ export function EditorPanelContent({
}
}, [documentId, searchSpaceId]);
+ const isEditableType = editorDoc
+ ? EDITABLE_DOCUMENT_TYPES.has(editorDoc.document_type ?? "")
+ : false;
+
return (
<>
{displayTitle}
- {editedMarkdown !== null && (
+ {isEditableType && editedMarkdown !== null && (
Unsaved changes
)}
@@ -193,7 +200,7 @@ export function EditorPanelContent({
{error || "An unknown error occurred"}
- ) : (
+ ) : isEditableType ? (
+ ) : (
+
+
+
)}
>
diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
index ef9ed1402..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 { 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";
@@ -103,6 +108,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
const resetCurrentThread = useSetAtom(resetCurrentThreadAtom);
const syncChatTab = useSetAtom(syncChatTabAtom);
const resetTabs = useSetAtom(resetTabsAtom);
+ const removeChatTab = useSetAtom(removeChatTabAtom);
// State for handling new chat navigation when router is out of sync
const [pendingNewChat, setPendingNewChat] = useState(false);
@@ -325,7 +331,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
const thread = threadsData?.threads?.find((t) => t.id === chatId);
syncChatTab({
chatId,
- title: thread?.title || (chatId ? `Chat ${chatId}` : "New Chat"),
+ // Avoid overwriting live SSE-updated tab titles with fallback values.
+ title: chatId ? (thread?.title ?? undefined) : "New Chat",
chatUrl,
});
}, [currentChatId, searchSpaceId, threadsData?.threads, syncChatTab]);
@@ -637,15 +644,20 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
setIsDeletingChat(true);
try {
await deleteThread(chatToDelete.id);
+ const fallbackTab = removeChatTab(chatToDelete.id);
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
if (currentChatId === chatToDelete.id) {
resetCurrentThread();
- const isOutOfSync = currentThreadState.id !== null && !params?.chat_id;
- if (isOutOfSync) {
- window.history.replaceState(null, "", `/dashboard/${searchSpaceId}/new-chat`);
- setChatResetKey((k) => k + 1);
+ if (fallbackTab?.type === "chat" && fallbackTab.chatUrl) {
+ router.push(fallbackTab.chatUrl);
} else {
- router.push(`/dashboard/${searchSpaceId}/new-chat`);
+ const isOutOfSync = currentThreadState.id !== null && !params?.chat_id;
+ if (isOutOfSync) {
+ window.history.replaceState(null, "", `/dashboard/${searchSpaceId}/new-chat`);
+ setChatResetKey((k) => k + 1);
+ } else {
+ router.push(`/dashboard/${searchSpaceId}/new-chat`);
+ }
}
}
} catch (error) {
@@ -664,6 +676,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
currentThreadState.id,
params?.chat_id,
router,
+ removeChatTab,
]);
// Rename handler
@@ -795,9 +808,10 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
confirmDeleteChat();
}}
disabled={isDeletingChat}
- className="bg-destructive text-destructive-foreground hover:bg-destructive/90 gap-2"
+ className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90 items-center justify-center"
>
- {isDeletingChat ? : tCommon("delete")}
+ {tCommon("delete")}
+ {isDeletingChat && }
@@ -835,15 +849,13 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
- {isRenamingChat ? (
- <>
-
- {tSidebar("renaming") || "Renaming"}
- >
- ) : (
- tSidebar("rename") || "Rename"
+
+ {tSidebar("rename") || "Rename"}
+
+ {isRenamingChat && (
+
)}
@@ -869,15 +881,11 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
confirmDeleteSearchSpace();
}}
disabled={isDeletingSearchSpace}
- className="bg-destructive text-destructive-foreground hover:bg-destructive/90 gap-2"
+ className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
- {isDeletingSearchSpace ? (
- <>
-
- {t("deleting")}
- >
- ) : (
- tCommon("delete")
+ {tCommon("delete")}
+ {isDeletingSearchSpace && (
+
)}
@@ -903,15 +911,11 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
confirmLeaveSearchSpace();
}}
disabled={isLeavingSearchSpace}
- className="bg-destructive text-destructive-foreground hover:bg-destructive/90 gap-2"
+ className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
- {isLeavingSearchSpace ? (
- <>
-
- {t("leaving")}
- >
- ) : (
- t("leave")
+ {t("leave")}
+ {isLeavingSearchSpace && (
+
)}
diff --git a/surfsense_web/components/layout/ui/header/Header.tsx b/surfsense_web/components/layout/ui/header/Header.tsx
index d2077deb4..cdf7a4fc4 100644
--- a/surfsense_web/components/layout/ui/header/Header.tsx
+++ b/surfsense_web/components/layout/ui/header/Header.tsx
@@ -1,20 +1,20 @@
"use client";
-import { useAtom, useAtomValue } from "jotai";
-import { PanelRight } from "lucide-react";
+import { useAtomValue } from "jotai";
import { usePathname } from "next/navigation";
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";
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
-import { activeTabAtom } from "@/atoms/tabs/tabs.atom";
+import { activeTabAtom, tabsAtom } from "@/atoms/tabs/tabs.atom";
import { ChatHeader } from "@/components/new-chat/chat-header";
import { ChatShareButton } from "@/components/new-chat/chat-share-button";
-import { Button } from "@/components/ui/button";
-import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useIsMobile } from "@/hooks/use-mobile";
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
+import { cn } from "@/lib/utils";
interface HeaderProps {
mobileMenuTrigger?: React.ReactNode;
@@ -25,9 +25,21 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const isMobile = useIsMobile();
const activeTab = useAtomValue(activeTabAtom);
+ const tabs = useAtomValue(tabsAtom);
+ const collapsed = useAtomValue(rightPanelCollapsedAtom);
+ const documentsOpen = useAtomValue(documentsSidebarOpenAtom);
+ const reportState = useAtomValue(reportPanelAtom);
+ const editorState = useAtomValue(editorPanelAtom);
+ const hitlEditState = useAtomValue(hitlEditPanelAtom);
const isChatPage = pathname?.includes("/new-chat") ?? false;
const isDocumentTab = activeTab?.type === "document";
+ 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 hasTabBar = tabs.length > 1;
const currentThreadState = useAtomValue(currentThreadAtom);
@@ -49,15 +61,8 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
const handleVisibilityChange = (_visibility: ChatVisibility) => {};
- const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom);
- const documentsOpen = useAtomValue(documentsSidebarOpenAtom);
- const reportState = useAtomValue(reportPanelAtom);
- const reportOpen = reportState.isOpen && !!reportState.reportId;
- const hasRightPanelContent = documentsOpen || reportOpen;
- const showExpandButton = !isMobile && collapsed && hasRightPanelContent;
-
return (
-