chore: ran linting

This commit is contained in:
Anish Sarkar 2026-03-30 01:50:41 +05:30
parent 74826b3714
commit 04691d572b
61 changed files with 1962 additions and 1516 deletions

View file

@ -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_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: def downgrade() -> None:

View file

@ -81,7 +81,8 @@ def create_create_onedrive_file_tool(
select(SearchSourceConnector).filter( select(SearchSourceConnector).filter(
SearchSourceConnector.search_space_id == search_space_id, SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id, SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type == SearchSourceConnectorType.ONEDRIVE_CONNECTOR, SearchSourceConnector.connector_type
== SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
) )
) )
connectors = result.scalars().all() connectors = result.scalars().all()
@ -95,12 +96,14 @@ def create_create_onedrive_file_tool(
accounts = [] accounts = []
for c in connectors: for c in connectors:
cfg = c.config or {} cfg = c.config or {}
accounts.append({ accounts.append(
{
"id": c.id, "id": c.id,
"name": c.name, "name": c.name,
"user_email": cfg.get("user_email"), "user_email": cfg.get("user_email"),
"auth_expired": cfg.get("auth_expired", False), "auth_expired": cfg.get("auth_expired", False),
}) }
)
if all(a.get("auth_expired") for a in accounts): if all(a.get("auth_expired") for a in accounts):
return { return {
@ -119,16 +122,22 @@ def create_create_onedrive_file_tool(
client = OneDriveClient(session=db_session, connector_id=cid) client = OneDriveClient(session=db_session, connector_id=cid)
items, err = await client.list_children("root") items, err = await client.list_children("root")
if err: 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] = [] parent_folders[cid] = []
else: else:
parent_folders[cid] = [ parent_folders[cid] = [
{"folder_id": item["id"], "name": item["name"]} {"folder_id": item["id"], "name": item["name"]}
for item in items 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: 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] = [] parent_folders[cid] = []
context: dict[str, Any] = { context: dict[str, Any] = {
@ -152,8 +161,12 @@ def create_create_onedrive_file_tool(
} }
) )
decisions_raw = approval.get("decisions", []) if isinstance(approval, dict) else [] decisions_raw = (
decisions = decisions_raw if isinstance(decisions_raw, list) else [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)] decisions = [d for d in decisions if isinstance(d, dict)]
if not decisions: if not decisions:
return {"status": "error", "message": "No approval decision received"} return {"status": "error", "message": "No approval decision received"}
@ -192,7 +205,8 @@ def create_create_onedrive_file_tool(
SearchSourceConnector.id == final_connector_id, SearchSourceConnector.id == final_connector_id,
SearchSourceConnector.search_space_id == search_space_id, SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id, SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type == SearchSourceConnectorType.ONEDRIVE_CONNECTOR, SearchSourceConnector.connector_type
== SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
) )
) )
connector = result.scalars().first() connector = result.scalars().first()
@ -200,7 +214,10 @@ def create_create_onedrive_file_tool(
connector = connectors[0] connector = connectors[0]
if not connector: 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 "") docx_bytes = _markdown_to_docx(final_content or "")
@ -212,7 +229,9 @@ def create_create_onedrive_file_tool(
mime_type=DOCX_MIME, 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 = "" kb_message_suffix = ""
try: try:

View file

@ -52,10 +52,15 @@ def create_delete_onedrive_file_tool(
- If status is "not_found", relay the exact message to the user and ask them - 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. 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: 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: try:
doc_result = await db_session.execute( doc_result = await db_session.execute(
@ -89,8 +94,12 @@ def create_delete_onedrive_file_tool(
Document.search_space_id == search_space_id, Document.search_space_id == search_space_id,
Document.document_type == DocumentType.ONEDRIVE_FILE, Document.document_type == DocumentType.ONEDRIVE_FILE,
func.lower( func.lower(
cast(Document.document_metadata["onedrive_file_name"], String) cast(
) == func.lower(file_name), Document.document_metadata["onedrive_file_name"],
String,
)
)
== func.lower(file_name),
SearchSourceConnector.user_id == user_id, SearchSourceConnector.user_id == user_id,
) )
) )
@ -110,14 +119,20 @@ def create_delete_onedrive_file_tool(
} }
if not document.connector_id: 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 {} meta = document.document_metadata or {}
file_id = meta.get("onedrive_file_id") file_id = meta.get("onedrive_file_id")
document_id = document.id document_id = document.id
if not file_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( conn_result = await db_session.execute(
select(SearchSourceConnector).filter( select(SearchSourceConnector).filter(
@ -125,13 +140,17 @@ def create_delete_onedrive_file_tool(
SearchSourceConnector.id == document.connector_id, SearchSourceConnector.id == document.connector_id,
SearchSourceConnector.search_space_id == search_space_id, SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id, SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type == SearchSourceConnectorType.ONEDRIVE_CONNECTOR, SearchSourceConnector.connector_type
== SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
) )
) )
) )
connector = conn_result.scalars().first() connector = conn_result.scalars().first()
if not connector: 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 {} cfg = connector.config or {}
if cfg.get("auth_expired"): if cfg.get("auth_expired"):
@ -170,8 +189,12 @@ def create_delete_onedrive_file_tool(
} }
) )
decisions_raw = approval.get("decisions", []) if isinstance(approval, dict) else [] decisions_raw = (
decisions = decisions_raw if isinstance(decisions_raw, list) else [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)] decisions = [d for d in decisions if isinstance(d, dict)]
if not decisions: if not decisions:
return {"status": "error", "message": "No approval decision received"} return {"status": "error", "message": "No approval decision received"}
@ -206,7 +229,8 @@ def create_delete_onedrive_file_tool(
SearchSourceConnector.id == final_connector_id, SearchSourceConnector.id == final_connector_id,
SearchSourceConnector.search_space_id == search_space_id, SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id, SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type == SearchSourceConnectorType.ONEDRIVE_CONNECTOR, SearchSourceConnector.connector_type
== SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
) )
) )
) )
@ -224,10 +248,14 @@ def create_delete_onedrive_file_tool(
f"Deleting OneDrive file: file_id='{final_file_id}', connector={actual_connector_id}" 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) 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] = { trash_result: dict[str, Any] = {
"status": "success", "status": "success",
@ -272,6 +300,9 @@ def create_delete_onedrive_file_tool(
if isinstance(e, GraphInterrupt): if isinstance(e, GraphInterrupt):
raise raise
logger.error(f"Error deleting OneDrive file: {e}", exc_info=True) 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 return delete_onedrive_file

View file

@ -39,7 +39,9 @@ class OneDriveClient:
cfg = connector.config or {} cfg = connector.config or {}
is_encrypted = cfg.get("_token_encrypted", False) 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", "") access_token = cfg.get("access_token", "")
refresh_token = cfg.get("refresh_token") refresh_token = cfg.get("refresh_token")
@ -206,13 +208,15 @@ class OneDriveClient:
async def download_file_to_disk(self, item_id: str, dest_path: str) -> 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.""" """Stream file content to disk. Returns error message on failure."""
token = await self._get_valid_token() token = await self._get_valid_token()
async with httpx.AsyncClient(follow_redirects=True) as client: async with (
async with client.stream( httpx.AsyncClient(follow_redirects=True) as client,
client.stream(
"GET", "GET",
f"{GRAPH_API_BASE}/me/drive/items/{item_id}/content", f"{GRAPH_API_BASE}/me/drive/items/{item_id}/content",
headers={"Authorization": f"Bearer {token}"}, headers={"Authorization": f"Bearer {token}"},
timeout=120.0, timeout=120.0,
) as resp: ) as resp,
):
if resp.status_code != 200: if resp.status_code != 200:
return f"Download failed: {resp.status_code}" return f"Download failed: {resp.status_code}"
with open(dest_path, "wb") as f: with open(dest_path, "wb") as f:

View file

@ -5,6 +5,7 @@ extension-based, not provider-specific.
""" """
import asyncio import asyncio
import contextlib
import logging import logging
import os import os
import tempfile import tempfile
@ -60,7 +61,9 @@ async def download_and_extract_content(
temp_file_path = None temp_file_path = None
try: 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: with tempfile.NamedTemporaryFile(delete=False, suffix=extension) as tmp:
temp_file_path = tmp.name temp_file_path = tmp.name
@ -76,10 +79,8 @@ async def download_and_extract_content(
return None, metadata, str(e) return None, metadata, str(e)
finally: finally:
if temp_file_path and os.path.exists(temp_file_path): if temp_file_path and os.path.exists(temp_file_path):
try: with contextlib.suppress(Exception):
os.unlink(temp_file_path) os.unlink(temp_file_path)
except Exception:
pass
async def _parse_file_to_markdown(file_path: str, filename: str) -> str: 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() return f.read()
if lower.endswith((".mp3", ".mp4", ".mpeg", ".mpga", ".m4a", ".wav", ".webm")): if lower.endswith((".mp3", ".mp4", ".mpeg", ".mpga", ".m4a", ".wav", ".webm")):
from app.config import config as app_config
from litellm import atranscription from litellm import atranscription
from app.config import config as app_config
stt_service_type = ( stt_service_type = (
"local" "local"
if app_config.STT_SERVICE and app_config.STT_SERVICE.startswith("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 from app.services.stt_service import stt_service
t0 = time.monotonic() 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) 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", "") text = result.get("text", "")
else: else:
with open(file_path, "rb") as audio_file: 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, 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) markdown_documents = await result.aget_markdown_documents(split_by_page=False)
if not markdown_documents: if not markdown_documents:
raise RuntimeError(f"LlamaCloud returned no documents for {filename}") 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() converter = DocumentConverter()
t0 = time.monotonic() 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) 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() return result.document.export_to_markdown()
raise RuntimeError(f"Unknown ETL_SERVICE: {app_config.ETL_SERVICE}") raise RuntimeError(f"Unknown ETL_SERVICE: {app_config.ETL_SERVICE}")

View file

@ -27,7 +27,10 @@ async def list_folder_contents(
if item["isFolder"]: if item["isFolder"]:
item.setdefault("mimeType", "application/vnd.ms-folder") item.setdefault("mimeType", "application/vnd.ms-folder")
else: 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())) 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 client, item["id"], include_subfolders=True
) )
if sub_error: 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 continue
files.extend(sub_files) files.extend(sub_files)
elif not should_skip_file(item): elif not should_skip_file(item):

View file

@ -33,9 +33,10 @@ from .new_llm_config_routes import router as new_llm_config_router
from .notes_routes import router as notes_router from .notes_routes import router as notes_router
from .notifications_routes import router as notifications_router from .notifications_routes import router as notifications_router
from .notion_add_connector_route import router as notion_add_connector_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 .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 .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 .rbac_routes import router as rbac_router
from .reports_routes import router as reports_router from .reports_routes import router as reports_router
from .sandbox_routes import router as sandbox_router from .sandbox_routes import router as sandbox_router
@ -44,7 +45,6 @@ from .search_spaces_routes import router as search_spaces_router
from .slack_add_connector_route import router as slack_add_connector_router from .slack_add_connector_route import router as slack_add_connector_router
from .surfsense_docs_routes import router as surfsense_docs_router from .surfsense_docs_routes import router as surfsense_docs_router
from .teams_add_connector_route import router as teams_add_connector_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 .video_presentations_routes import router as video_presentations_router
from .youtube_routes import router as youtube_router from .youtube_routes import router as youtube_router

View file

@ -79,9 +79,13 @@ async def connect_onedrive(space_id: int, user: User = Depends(current_active_us
if not space_id: if not space_id:
raise HTTPException(status_code=400, detail="space_id is required") raise HTTPException(status_code=400, detail="space_id is required")
if not config.MICROSOFT_CLIENT_ID: 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: 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_manager = get_state_manager()
state_encoded = state_manager.generate_secure_state(space_id, user.id) 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)}" 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} return {"auth_url": auth_url}
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error("Failed to initiate OneDrive OAuth: %s", str(e), exc_info=True) 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") @router.get("/auth/onedrive/connector/reauth")
@ -121,15 +129,20 @@ async def reauth_onedrive(
SearchSourceConnector.id == connector_id, SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user.id, SearchSourceConnector.user_id == user.id,
SearchSourceConnector.search_space_id == space_id, SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.connector_type == SearchSourceConnectorType.ONEDRIVE_CONNECTOR, SearchSourceConnector.connector_type
== SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
) )
) )
connector = result.scalars().first() connector = result.scalars().first()
if not connector: 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: 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_manager = get_state_manager()
extra: dict = {"connector_id": connector_id} extra: dict = {"connector_id": connector_id}
@ -148,14 +161,20 @@ async def reauth_onedrive(
} }
auth_url = f"{AUTHORIZATION_URL}?{urlencode(auth_params)}" 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} return {"auth_url": auth_url}
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error("Failed to initiate OneDrive re-auth: %s", str(e), exc_info=True) 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") @router.get("/auth/onedrive/connector/callback")
@ -182,10 +201,14 @@ async def onedrive_callback(
return RedirectResponse( return RedirectResponse(
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/connectors/callback?error=onedrive_oauth_denied" 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: 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() state_manager = get_state_manager()
try: try:
@ -194,7 +217,9 @@ async def onedrive_callback(
user_id = UUID(data["user_id"]) user_id = UUID(data["user_id"])
except (HTTPException, ValueError, KeyError) as e: except (HTTPException, ValueError, KeyError) as e:
logger.error("Invalid OAuth state: %s", str(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_connector_id = data.get("connector_id")
reauth_return_url = data.get("return_url") reauth_return_url = data.get("return_url")
@ -222,20 +247,26 @@ async def onedrive_callback(
error_detail = error_json.get("error_description", error_detail) error_detail = error_json.get("error_description", error_detail)
except Exception: except Exception:
pass 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() token_json = token_response.json()
access_token = token_json.get("access_token") access_token = token_json.get("access_token")
refresh_token = token_json.get("refresh_token") refresh_token = token_json.get("refresh_token")
if not access_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() token_encryption = get_token_encryption()
expires_at = None expires_at = None
if token_json.get("expires_in"): 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 = {} user_info: dict = {}
try: try:
@ -248,7 +279,8 @@ async def onedrive_callback(
if user_response.status_code == 200: if user_response.status_code == 200:
user_data = user_response.json() user_data = user_response.json()
user_info = { 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"), "user_name": user_data.get("displayName"),
} }
except Exception as e: except Exception as e:
@ -256,7 +288,9 @@ async def onedrive_callback(
connector_config = { connector_config = {
"access_token": token_encryption.encrypt_token(access_token), "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"), "token_type": token_json.get("token_type", "Bearer"),
"expires_in": token_json.get("expires_in"), "expires_in": token_json.get("expires_in"),
"expires_at": expires_at.isoformat() if expires_at else None, "expires_at": expires_at.isoformat() if expires_at else None,
@ -273,22 +307,36 @@ async def onedrive_callback(
SearchSourceConnector.id == reauth_connector_id, SearchSourceConnector.id == reauth_connector_id,
SearchSourceConnector.user_id == user_id, SearchSourceConnector.user_id == user_id,
SearchSourceConnector.search_space_id == space_id, SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.connector_type == SearchSourceConnectorType.ONEDRIVE_CONNECTOR, SearchSourceConnector.connector_type
== SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
) )
) )
db_connector = result.scalars().first() db_connector = result.scalars().first()
if not db_connector: 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") 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") flag_modified(db_connector, "config")
await session.commit() await session.commit()
await session.refresh(db_connector) 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("/"): 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( return RedirectResponse(
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/connectors/callback?success=true&connector=ONEDRIVE_CONNECTOR&connectorId={db_connector.id}" 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 SearchSourceConnectorType.ONEDRIVE_CONNECTOR, connector_config
) )
is_duplicate = await check_duplicate_connector( 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: 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( return RedirectResponse(
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/connectors/callback?error=duplicate_account&connector=ONEDRIVE_CONNECTOR" url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/connectors/callback?error=duplicate_account&connector=ONEDRIVE_CONNECTOR"
) )
connector_name = await generate_unique_connector_name( 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( new_connector = SearchSourceConnector(
@ -323,20 +381,30 @@ async def onedrive_callback(
session.add(new_connector) session.add(new_connector)
await session.commit() await session.commit()
await session.refresh(new_connector) 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( return RedirectResponse(
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/connectors/callback?success=true&connector=ONEDRIVE_CONNECTOR&connectorId={new_connector.id}" url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/connectors/callback?success=true&connector=ONEDRIVE_CONNECTOR&connectorId={new_connector.id}"
) )
except IntegrityError as e: except IntegrityError as e:
await session.rollback() await session.rollback()
logger.error("Database integrity error creating OneDrive connector: %s", str(e)) logger.error(
return RedirectResponse(url=f"{config.NEXT_FRONTEND_URL}/dashboard?error=connector_creation_failed") "Database integrity error creating OneDrive connector: %s", str(e)
)
return RedirectResponse(
url=f"{config.NEXT_FRONTEND_URL}/dashboard?error=connector_creation_failed"
)
except HTTPException: except HTTPException:
raise raise
except (IntegrityError, ValueError) as e: except (IntegrityError, ValueError) as e:
logger.error("OneDrive OAuth callback error: %s", str(e), exc_info=True) 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") @router.get("/connectors/{connector_id}/onedrive/folders")
@ -353,28 +421,44 @@ async def list_onedrive_folders(
select(SearchSourceConnector).filter( select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id, SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user.id, SearchSourceConnector.user_id == user.id,
SearchSourceConnector.connector_type == SearchSourceConnectorType.ONEDRIVE_CONNECTOR, SearchSourceConnector.connector_type
== SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
) )
) )
connector = result.scalars().first() connector = result.scalars().first()
if not connector: 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) onedrive_client = OneDriveClient(session, connector_id)
items, error = await list_folder_contents(onedrive_client, parent_id=parent_id) items, error = await list_folder_contents(onedrive_client, parent_id=parent_id)
if error: if error:
error_lower = error.lower() 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: try:
if connector and not connector.config.get("auth_expired"): if connector and not connector.config.get("auth_expired"):
connector.config = {**connector.config, "auth_expired": True} connector.config = {**connector.config, "auth_expired": True}
flag_modified(connector, "config") flag_modified(connector, "config")
await session.commit() await session.commit()
except Exception: except Exception:
logger.warning("Failed to persist auth_expired for connector %s", connector_id, exc_info=True) logger.warning(
raise HTTPException(status_code=400, detail="OneDrive authentication expired. Please re-authenticate.") "Failed to persist auth_expired for connector %s",
raise HTTPException(status_code=500, detail=f"Failed to list folder contents: {error}") 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} return {"items": items}
@ -391,8 +475,13 @@ async def list_onedrive_folders(
await session.commit() await session.commit()
except Exception: except Exception:
pass pass
raise HTTPException(status_code=400, detail="OneDrive authentication expired. Please re-authenticate.") from e raise HTTPException(
raise HTTPException(status_code=500, detail=f"Failed to list OneDrive contents: {e!s}") from e 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( async def refresh_onedrive_token(
@ -410,10 +499,15 @@ async def refresh_onedrive_token(
refresh_token = token_encryption.decrypt_token(refresh_token) refresh_token = token_encryption.decrypt_token(refresh_token)
except Exception as e: except Exception as e:
logger.error("Failed to decrypt refresh token: %s", str(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: 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 = { refresh_data = {
"client_id": config.MICROSOFT_CLIENT_ID, "client_id": config.MICROSOFT_CLIENT_ID,
@ -425,8 +519,10 @@ async def refresh_onedrive_token(
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
token_response = await client.post( token_response = await client.post(
TOKEN_URL, data=refresh_data, TOKEN_URL,
headers={"Content-Type": "application/x-www-form-urlencoded"}, timeout=30.0, data=refresh_data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=30.0,
) )
if token_response.status_code != 200: if token_response.status_code != 200:
@ -439,16 +535,27 @@ async def refresh_onedrive_token(
except Exception: except Exception:
pass pass
error_lower = (error_detail + error_code).lower() error_lower = (error_detail + error_code).lower()
if "invalid_grant" in error_lower or "expired" in error_lower or "revoked" in error_lower: if (
raise HTTPException(status_code=401, detail="OneDrive authentication failed. Please re-authenticate.") "invalid_grant" in error_lower
raise HTTPException(status_code=400, detail=f"Token refresh failed: {error_detail}") 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() token_json = token_response.json()
access_token = token_json.get("access_token") access_token = token_json.get("access_token")
new_refresh_token = token_json.get("refresh_token") new_refresh_token = token_json.get("refresh_token")
if not access_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_at = None
expires_in = token_json.get("expires_in") expires_in = token_json.get("expires_in")

View file

@ -2567,8 +2567,12 @@ async def run_onedrive_indexing(
search_space_id=search_space_id, search_space_id=search_space_id,
folder_count=len(items_dict.get("folders", [])), folder_count=len(items_dict.get("folders", [])),
file_count=len(items_dict.get("files", [])), file_count=len(items_dict.get("files", [])),
folder_names=[f.get("name", "Unknown") for f in items_dict.get("folders", [])], folder_names=[
file_names=[f.get("name", "Unknown") for f in items_dict.get("files", [])], 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: if notification:
@ -2593,7 +2597,9 @@ async def run_onedrive_indexing(
) )
if _is_auth_error(error_message): if _is_auth_error(error_message):
await _persist_auth_expired(session, connector_id) await _persist_auth_expired(session, connector_id)
error_message = "OneDrive authentication expired. Please re-authenticate." error_message = (
"OneDrive authentication expired. Please re-authenticate."
)
else: else:
if notification: if notification:
await session.refresh(notification) await session.refresh(notification)

View file

@ -56,9 +56,7 @@ class OneDriveKBSyncService:
indexable_content = (content or "").strip() indexable_content = (content or "").strip()
if not indexable_content: if not indexable_content:
indexable_content = ( indexable_content = f"OneDrive file: {file_name} (type: {mime_type})"
f"OneDrive file: {file_name} (type: {mime_type})"
)
content_hash = generate_content_hash(indexable_content, search_space_id) content_hash = generate_content_hash(indexable_content, search_space_id)
@ -95,9 +93,7 @@ class OneDriveKBSyncService:
) )
else: else:
logger.warning("No LLM configured — using fallback summary") logger.warning("No LLM configured — using fallback summary")
summary_content = ( summary_content = f"OneDrive File: {file_name}\n\n{indexable_content}"
f"OneDrive File: {file_name}\n\n{indexable_content}"
)
summary_embedding = embed_text(summary_content) summary_embedding = embed_text(summary_content)
chunks = await create_document_chunks(indexable_content) chunks = await create_document_chunks(indexable_content)

View file

@ -1076,7 +1076,11 @@ async def _stream_agent_events(
}, },
) )
elif tool_name == "web_search": elif tool_name == "web_search":
xml = tool_output.get("result", str(tool_output)) if isinstance(tool_output, dict) else str(tool_output) xml = (
tool_output.get("result", str(tool_output))
if isinstance(tool_output, dict)
else str(tool_output)
)
citations: dict[str, dict[str, str]] = {} citations: dict[str, dict[str, str]] = {}
for m in re.finditer( for m in re.finditer(
r"<title><!\[CDATA\[(.*?)\]\]></title>\s*<url><!\[CDATA\[(.*?)\]\]></url>", r"<title><!\[CDATA\[(.*?)\]\]></title>\s*<url><!\[CDATA\[(.*?)\]\]></url>",

View file

@ -45,6 +45,7 @@ logger = logging.getLogger(__name__)
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def _should_skip_file( async def _should_skip_file(
session: AsyncSession, session: AsyncSession,
file: dict, file: dict,
@ -186,9 +187,13 @@ async def _download_files_parallel(
logger.warning(f"Download/ETL failed for {file_name}: {reason}") logger.warning(f"Download/ETL failed for {file_name}: {reason}")
return None return None
doc = _build_connector_doc( doc = _build_connector_doc(
file, markdown, od_metadata, file,
connector_id=connector_id, search_space_id=search_space_id, markdown,
user_id=user_id, enable_summary=enable_summary, od_metadata,
connector_id=connector_id,
search_space_id=search_space_id,
user_id=user_id,
enable_summary=enable_summary,
) )
async with hb_lock: async with hb_lock:
completed_count += 1 completed_count += 1
@ -204,9 +209,7 @@ async def _download_files_parallel(
failed = 0 failed = 0
for outcome in outcomes: for outcome in outcomes:
if isinstance(outcome, Exception): if isinstance(outcome, Exception) or outcome is None:
failed += 1
elif outcome is None:
failed += 1 failed += 1
else: else:
results.append(outcome) results.append(outcome)
@ -227,9 +230,12 @@ async def _download_and_index(
) -> tuple[int, int]: ) -> tuple[int, int]:
"""Parallel download then parallel indexing. Returns (batch_indexed, total_failed).""" """Parallel download then parallel indexing. Returns (batch_indexed, total_failed)."""
connector_docs, download_failed = await _download_files_parallel( connector_docs, download_failed = await _download_files_parallel(
onedrive_client, files, onedrive_client,
connector_id=connector_id, search_space_id=search_space_id, files,
user_id=user_id, enable_summary=enable_summary, connector_id=connector_id,
search_space_id=search_space_id,
user_id=user_id,
enable_summary=enable_summary,
on_heartbeat=on_heartbeat, on_heartbeat=on_heartbeat,
) )
@ -242,7 +248,9 @@ async def _download_and_index(
return await get_user_long_context_llm(s, user_id, search_space_id) return await get_user_long_context_llm(s, user_id, search_space_id)
_, batch_indexed, batch_failed = await pipeline.index_batch_parallel( _, 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, on_heartbeat=on_heartbeat,
) )
@ -305,10 +313,14 @@ async def _index_selected_files(
files_to_download.append(file) files_to_download.append(file)
batch_indexed, failed = await _download_and_index( batch_indexed, _failed = await _download_and_index(
onedrive_client, session, files_to_download, onedrive_client,
connector_id=connector_id, search_space_id=search_space_id, session,
user_id=user_id, enable_summary=enable_summary, 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, on_heartbeat=on_heartbeat,
) )
@ -319,6 +331,7 @@ async def _index_selected_files(
# Scan strategies # Scan strategies
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def _index_full_scan( async def _index_full_scan(
onedrive_client: OneDriveClient, onedrive_client: OneDriveClient,
session: AsyncSession, session: AsyncSession,
@ -338,7 +351,11 @@ async def _index_full_scan(
await task_logger.log_task_progress( await task_logger.log_task_progress(
log_entry, log_entry,
f"Starting full scan of folder: {folder_name}", 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 renamed_count = 0
@ -346,12 +363,16 @@ async def _index_full_scan(
files_to_download: list[dict] = [] files_to_download: list[dict] = []
all_files, error = await get_files_in_folder( 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: if error:
err_lower = error.lower() err_lower = error.lower()
if "401" in error or "authentication expired" in err_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}") raise Exception(f"Failed to list OneDrive files: {error}")
for file in all_files[:max_files]: for file in all_files[:max_files]:
@ -365,14 +386,20 @@ async def _index_full_scan(
files_to_download.append(file) files_to_download.append(file)
batch_indexed, failed = await _download_and_index( batch_indexed, failed = await _download_and_index(
onedrive_client, session, files_to_download, onedrive_client,
connector_id=connector_id, search_space_id=search_space_id, session,
user_id=user_id, enable_summary=enable_summary, 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, on_heartbeat=on_heartbeat_callback,
) )
indexed = renamed_count + batch_indexed 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 return indexed, skipped
@ -392,7 +419,8 @@ async def _index_with_delta_sync(
) -> tuple[int, int, str | None]: ) -> tuple[int, int, str | None]:
"""Delta sync using OneDrive change tracking. Returns (indexed, skipped, new_delta_link).""" """Delta sync using OneDrive change tracking. Returns (indexed, skipped, new_delta_link)."""
await task_logger.log_task_progress( await task_logger.log_task_progress(
log_entry, "Starting delta sync", log_entry,
"Starting delta sync",
{"stage": "delta_sync"}, {"stage": "delta_sync"},
) )
@ -402,7 +430,9 @@ async def _index_with_delta_sync(
if error: if error:
err_lower = error.lower() err_lower = error.lower()
if "401" in error or "authentication expired" in err_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}") raise Exception(f"Failed to fetch OneDrive changes: {error}")
if not changes: if not changes:
@ -444,14 +474,20 @@ async def _index_with_delta_sync(
files_to_download.append(change) files_to_download.append(change)
batch_indexed, failed = await _download_and_index( batch_indexed, failed = await _download_and_index(
onedrive_client, session, files_to_download, onedrive_client,
connector_id=connector_id, search_space_id=search_space_id, session,
user_id=user_id, enable_summary=enable_summary, 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, on_heartbeat=on_heartbeat_callback,
) )
indexed = renamed_count + batch_indexed 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 return indexed, skipped, new_delta_link
@ -459,6 +495,7 @@ async def _index_with_delta_sync(
# Public entry point # Public entry point
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def index_onedrive_files( async def index_onedrive_files(
session: AsyncSession, session: AsyncSession,
connector_id: int, connector_id: int,
@ -489,13 +526,20 @@ async def index_onedrive_files(
) )
if not connector: if not connector:
error_msg = f"OneDrive connector with ID {connector_id} not found" 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 return 0, 0, error_msg
token_encrypted = connector.config.get("_token_encrypted", False) token_encrypted = connector.config.get("_token_encrypted", False)
if token_encrypted and not config.SECRET_KEY: if token_encrypted and not config.SECRET_KEY:
error_msg = "SECRET_KEY not configured but credentials are encrypted" 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 return 0, 0, error_msg
connector_enable_summary = getattr(connector, "enable_summary", True) connector_enable_summary = getattr(connector, "enable_summary", True)
@ -513,10 +557,14 @@ async def index_onedrive_files(
selected_files = items_dict.get("files", []) selected_files = items_dict.get("files", [])
if selected_files: if selected_files:
file_tuples = [(f["id"], f.get("name")) for f in selected_files] file_tuples = [(f["id"], f.get("name")) for f in selected_files]
indexed, skipped, errors = await _index_selected_files( indexed, skipped, _errors = await _index_selected_files(
onedrive_client, session, file_tuples, onedrive_client,
connector_id=connector_id, search_space_id=search_space_id, session,
user_id=user_id, enable_summary=connector_enable_summary, 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_indexed += indexed
total_skipped += skipped total_skipped += skipped
@ -534,8 +582,16 @@ async def index_onedrive_files(
if can_use_delta: if can_use_delta:
logger.info(f"Using delta sync for folder {folder_name}") logger.info(f"Using delta sync for folder {folder_name}")
indexed, skipped, new_delta_link = await _index_with_delta_sync( indexed, skipped, new_delta_link = await _index_with_delta_sync(
onedrive_client, session, connector_id, search_space_id, user_id, onedrive_client,
folder_id, delta_link, task_logger, log_entry, max_files, session,
connector_id,
search_space_id,
user_id,
folder_id,
delta_link,
task_logger,
log_entry,
max_files,
enable_summary=connector_enable_summary, enable_summary=connector_enable_summary,
) )
total_indexed += indexed total_indexed += indexed
@ -550,18 +606,36 @@ async def index_onedrive_files(
# Reconciliation full scan # Reconciliation full scan
ri, rs = await _index_full_scan( ri, rs = await _index_full_scan(
onedrive_client, session, connector_id, search_space_id, user_id, onedrive_client,
folder_id, folder_name, task_logger, log_entry, max_files, session,
include_subfolders, enable_summary=connector_enable_summary, 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_indexed += ri
total_skipped += rs total_skipped += rs
else: else:
logger.info(f"Using full scan for folder {folder_name}") logger.info(f"Using full scan for folder {folder_name}")
indexed, skipped = await _index_full_scan( indexed, skipped = await _index_full_scan(
onedrive_client, session, connector_id, search_space_id, user_id, onedrive_client,
folder_id, folder_name, task_logger, log_entry, max_files, session,
include_subfolders, enable_summary=connector_enable_summary, 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_indexed += indexed
total_skipped += skipped total_skipped += skipped
@ -585,22 +659,28 @@ async def index_onedrive_files(
f"Successfully completed OneDrive indexing for connector {connector_id}", f"Successfully completed OneDrive indexing for connector {connector_id}",
{"files_processed": total_indexed, "files_skipped": total_skipped}, {"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 return total_indexed, total_skipped, None
except SQLAlchemyError as db_error: except SQLAlchemyError as db_error:
await session.rollback() await session.rollback()
await task_logger.log_task_failure( await task_logger.log_task_failure(
log_entry, f"Database error during OneDrive indexing for connector {connector_id}", log_entry,
str(db_error), {"error_type": "SQLAlchemyError"}, 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) logger.error(f"Database error: {db_error!s}", exc_info=True)
return 0, 0, f"Database error: {db_error!s}" return 0, 0, f"Database error: {db_error!s}"
except Exception as e: except Exception as e:
await session.rollback() await session.rollback()
await task_logger.log_task_failure( await task_logger.log_task_failure(
log_entry, f"Failed to index OneDrive files for connector {connector_id}", log_entry,
str(e), {"error_type": type(e).__name__}, 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) logger.error(f"Failed to index OneDrive files: {e!s}", exc_info=True)
return 0, 0, f"Failed to index OneDrive files: {e!s}" return 0, 0, f"Failed to index OneDrive files: {e!s}"

View file

@ -13,7 +13,9 @@ _EMBEDDING_DIM = app_config.embedding_model_instance.dimension
pytestmark = pytest.mark.integration 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( return ConnectorDocument(
title=f"File {unique_id}.docx", title=f"File {unique_id}.docx",
source_markdown=f"## Document\n\nContent from {unique_id}", 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( async def test_onedrive_pipeline_creates_ready_document(
db_session, db_search_space, db_connector, db_user, mocker 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) 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( async def test_onedrive_duplicate_content_skipped(
db_session, db_search_space, db_connector, db_user, mocker 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() first_doc = result.scalars().first()
assert first_doc is not None assert first_doc is not None
first_id = first_doc.id
doc2 = _onedrive_doc( doc2 = _onedrive_doc(
unique_id="od-dup-file", unique_id="od-dup-file",
search_space_id=space_id, search_space_id=space_id,
@ -97,4 +101,6 @@ async def test_onedrive_duplicate_content_skipped(
) )
prepared2 = await service.prepare_for_indexing([doc2]) 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
)

View file

@ -48,12 +48,14 @@ def patch_extract(monkeypatch):
mock, mock,
) )
return mock return mock
return _patch return _patch
# Slice 1: Tracer bullet # Slice 1: Tracer bullet
async def test_single_file_returns_one_connector_document( 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")) 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 # Slice 2: Multiple files all produce documents
async def test_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)] files = [_make_file_dict(f"f{i}", f"file{i}.txt") for i in range(3)]
patch_extract( patch_extract(
@ -98,7 +101,8 @@ async def test_multiple_files_all_produce_documents(
# Slice 3: Error isolation # Slice 3: Error isolation
async def test_one_download_exception_does_not_block_others( 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)] files = [_make_file_dict(f"f{i}", f"file{i}.txt") for i in range(3)]
patch_extract( patch_extract(
@ -125,7 +129,8 @@ async def test_one_download_exception_does_not_block_others(
# Slice 4: ETL error counts as download failure # Slice 4: ETL error counts as download failure
async def test_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")] files = [_make_file_dict("f0", "good.txt"), _make_file_dict("f1", "bad.txt")]
patch_extract( patch_extract(
@ -150,7 +155,8 @@ async def test_etl_error_counts_as_download_failure(
# Slice 5: Semaphore bound # Slice 5: Semaphore bound
async def test_concurrency_bounded_by_semaphore( async def test_concurrency_bounded_by_semaphore(
mock_onedrive_client, monkeypatch, mock_onedrive_client,
monkeypatch,
): ):
lock = asyncio.Lock() lock = asyncio.Lock()
active = 0 active = 0
@ -190,7 +196,8 @@ async def test_concurrency_bounded_by_semaphore(
# Slice 6: Heartbeat fires # Slice 6: Heartbeat fires
async def test_heartbeat_fires_during_parallel_downloads( 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 import app.tasks.connector_indexers.onedrive_indexer as _mod

View file

@ -744,7 +744,11 @@ export function DocumentsTableShell({
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48"> <DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => onOpenInTab ? onOpenInTab(doc) : handleViewDocument(doc)}> <DropdownMenuItem
onClick={() =>
onOpenInTab ? onOpenInTab(doc) : handleViewDocument(doc)
}
>
<Eye className="h-4 w-4" /> <Eye className="h-4 w-4" />
Open Open
</DropdownMenuItem> </DropdownMenuItem>

View file

@ -190,9 +190,7 @@ export default function OnboardPage() {
<span className={isSubmitting ? "opacity-0" : ""}>Start Using SurfSense</span> <span className={isSubmitting ? "opacity-0" : ""}>Start Using SurfSense</span>
{isSubmitting && <Spinner size="sm" className="absolute" />} {isSubmitting && <Spinner size="sm" className="absolute" />}
</Button> </Button>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">You can add more configurations later</p>
You can add more configurations later
</p>
</div> </div>
</div> </div>
</div> </div>

View file

@ -308,7 +308,8 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) {
{invitesLoading ? ( {invitesLoading ? (
<Skeleton className="h-9 w-32 rounded-md" /> <Skeleton className="h-9 w-32 rounded-md" />
) : ( ) : (
canInvite && activeInvites.length > 0 && ( canInvite &&
activeInvites.length > 0 && (
<AllInvitesDialog invites={activeInvites} onRevokeInvite={handleRevokeInvite} /> <AllInvitesDialog invites={activeInvites} onRevokeInvite={handleRevokeInvite} />
) )
)} )}

View file

@ -3,11 +3,11 @@
import { PenLine, Plus, Sparkles, Trash2 } from "lucide-react"; import { PenLine, Plus, Sparkles, Trash2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { PromptRead } from "@/contracts/types/prompts.types";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import type { PromptRead } from "@/contracts/types/prompts.types";
import { promptsApiService } from "@/lib/apis/prompts-api.service"; import { promptsApiService } from "@/lib/apis/prompts-api.service";
interface PromptFormData { interface PromptFormData {
@ -99,7 +99,9 @@ export function PromptsContent() {
<div className="space-y-6 min-w-0 overflow-hidden"> <div className="space-y-6 min-w-0 overflow-hidden">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Create prompt templates triggered with <kbd className="rounded border bg-muted px-1.5 py-0.5 text-xs font-mono">/</kbd> in the chat composer. Create prompt templates triggered with{" "}
<kbd className="rounded border bg-muted px-1.5 py-0.5 text-xs font-mono">/</kbd> in the
chat composer.
</p> </p>
{!showForm && ( {!showForm && (
<Button <Button
@ -144,7 +146,11 @@ export function PromptsContent() {
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm outline-none resize-none focus:ring-1 focus:ring-ring" className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm outline-none resize-none focus:ring-1 focus:ring-ring"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Use <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">{"{selection}"}</code> to insert the input text. If omitted, the text is appended automatically. Use{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">
{"{selection}"}
</code>{" "}
to insert the input text. If omitted, the text is appended automatically.
</p> </p>
</div> </div>
@ -153,7 +159,9 @@ export function PromptsContent() {
<select <select
id="prompt-mode" id="prompt-mode"
value={formData.mode} value={formData.mode}
onChange={(e) => 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" className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring"
> >
<option value="transform">Transform rewrites or modifies your text</option> <option value="transform">Transform rewrites or modifies your text</option>

View file

@ -78,7 +78,10 @@ export const deleteNewLLMConfigMutationAtom = atomWithMutation((get) => {
mutationFn: async (request: DeleteNewLLMConfigRequest & { name: string }) => { mutationFn: async (request: DeleteNewLLMConfigRequest & { name: string }) => {
return newLLMConfigApiService.deleteConfig({ id: request.id }); return newLLMConfigApiService.deleteConfig({ id: request.id });
}, },
onSuccess: (_: DeleteNewLLMConfigResponse, request: DeleteNewLLMConfigRequest & { name: string }) => { onSuccess: (
_: DeleteNewLLMConfigResponse,
request: DeleteNewLLMConfigRequest & { name: string }
) => {
toast.success(`${request.name} deleted`); toast.success(`${request.name} deleted`);
queryClient.setQueryData( queryClient.setQueryData(
cacheKeys.newLLMConfigs.all(Number(searchSpaceId)), cacheKeys.newLLMConfigs.all(Number(searchSpaceId)),

View file

@ -143,9 +143,7 @@ export const updateChatTabTitleAtom = atom(
set(tabsStateAtom, { set(tabsStateAtom, {
...state, ...state,
activeTabId: tabId, activeTabId: tabId,
tabs: state.tabs.map((t) => tabs: state.tabs.map((t) => (t.id === "chat-new" ? { ...t, id: tabId, chatId, title } : t)),
t.id === "chat-new" ? { ...t, id: tabId, chatId, title } : t
),
}); });
return; return;
} }

View file

@ -7,7 +7,14 @@ import {
useAuiState, useAuiState,
} from "@assistant-ui/react"; } from "@assistant-ui/react";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { CheckIcon, ClipboardPaste, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react"; import {
CheckIcon,
ClipboardPaste,
CopyIcon,
DownloadIcon,
MessageSquare,
RefreshCwIcon,
} from "lucide-react";
import type { FC } from "react"; import type { FC } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { commentsEnabledAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom"; import { commentsEnabledAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
@ -41,10 +48,6 @@ import {
CreateGoogleDriveFileToolUI, CreateGoogleDriveFileToolUI,
DeleteGoogleDriveFileToolUI, DeleteGoogleDriveFileToolUI,
} from "@/components/tool-ui/google-drive"; } from "@/components/tool-ui/google-drive";
import {
CreateOneDriveFileToolUI,
DeleteOneDriveFileToolUI,
} from "@/components/tool-ui/onedrive";
import { import {
CreateJiraIssueToolUI, CreateJiraIssueToolUI,
DeleteJiraIssueToolUI, DeleteJiraIssueToolUI,
@ -60,6 +63,7 @@ import {
DeleteNotionPageToolUI, DeleteNotionPageToolUI,
UpdateNotionPageToolUI, UpdateNotionPageToolUI,
} from "@/components/tool-ui/notion"; } from "@/components/tool-ui/notion";
import { CreateOneDriveFileToolUI, DeleteOneDriveFileToolUI } from "@/components/tool-ui/onedrive";
import { SandboxExecuteToolUI } from "@/components/tool-ui/sandbox-execute"; import { SandboxExecuteToolUI } from "@/components/tool-ui/sandbox-execute";
import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory"; import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory";
import { GenerateVideoPresentationToolUI } from "@/components/tool-ui/video-presentation"; import { GenerateVideoPresentationToolUI } from "@/components/tool-ui/video-presentation";

View file

@ -24,7 +24,9 @@ interface MessageContent {
} }
export const CitationMetadataProvider: FC<{ children: ReactNode }> = ({ children }) => { export const CitationMetadataProvider: FC<{ children: ReactNode }> = ({ children }) => {
const content = useAuiState(({ message }) => (message as { content?: MessageContent[] })?.content); const content = useAuiState(
({ message }) => (message as { content?: MessageContent[] })?.content
);
const metadataMap = useMemo<CitationMetadataMap>(() => { const metadataMap = useMemo<CitationMetadataMap>(() => {
if (!content || !Array.isArray(content)) return new Map(); if (!content || !Array.isArray(content)) return new Map();
@ -51,7 +53,9 @@ export const CitationMetadataProvider: FC<{ children: ReactNode }> = ({ children
}, [content]); }, [content]);
return ( return (
<CitationMetadataContext.Provider value={metadataMap}>{children}</CitationMetadataContext.Provider> <CitationMetadataContext.Provider value={metadataMap}>
{children}
</CitationMetadataContext.Provider>
); );
}; };

View file

@ -198,7 +198,6 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
return ( return (
<Dialog open={isOpen} modal={false} onOpenChange={handleOpenChange}> <Dialog open={isOpen} modal={false} onOpenChange={handleOpenChange}>
{isOpen && {isOpen &&
createPortal( createPortal(
<div <div

View file

@ -212,8 +212,7 @@ export const OneDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfigCh
{isAuthExpired && ( {isAuthExpired && (
<p className="text-xs text-amber-600 dark:text-amber-500"> <p className="text-xs text-amber-600 dark:text-amber-500">
Your OneDrive authentication has expired. Please re-authenticate using the button Your OneDrive authentication has expired. Please re-authenticate using the button below.
below.
</p> </p>
)} )}

View file

@ -19,9 +19,9 @@ import { LinkupApiConfig } from "./components/linkup-api-config";
import { LumaConfig } from "./components/luma-config"; import { LumaConfig } from "./components/luma-config";
import { MCPConfig } from "./components/mcp-config"; import { MCPConfig } from "./components/mcp-config";
import { ObsidianConfig } from "./components/obsidian-config"; import { ObsidianConfig } from "./components/obsidian-config";
import { OneDriveConfig } from "./components/onedrive-config";
import { SlackConfig } from "./components/slack-config"; import { SlackConfig } from "./components/slack-config";
import { TavilyApiConfig } from "./components/tavily-api-config"; import { TavilyApiConfig } from "./components/tavily-api-config";
import { OneDriveConfig } from "./components/onedrive-config";
import { TeamsConfig } from "./components/teams-config"; import { TeamsConfig } from "./components/teams-config";
import { WebcrawlerConfig } from "./components/webcrawler-config"; import { WebcrawlerConfig } from "./components/webcrawler-config";

View file

@ -544,7 +544,12 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
} }
} }
if (slashIndex !== -1 && (slashIndex === 0 || textContent[slashIndex - 1] === " " || textContent[slashIndex - 1] === "\n")) { if (
slashIndex !== -1 &&
(slashIndex === 0 ||
textContent[slashIndex - 1] === " " ||
textContent[slashIndex - 1] === "\n")
) {
const query = textContent.slice(slashIndex + 1, cursorPos); const query = textContent.slice(slashIndex + 1, cursorPos);
if (!query.startsWith(" ")) { if (!query.startsWith(" ")) {
shouldTriggerAction = true; shouldTriggerAction = true;
@ -575,7 +580,15 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
// Notify parent of change // Notify parent of change
onChange?.(text, Array.from(mentionedDocs.values())); onChange?.(text, Array.from(mentionedDocs.values()));
}, [getText, mentionedDocs, onChange, onMentionTrigger, onMentionClose, onActionTrigger, onActionClose]); }, [
getText,
mentionedDocs,
onChange,
onMentionTrigger,
onMentionClose,
onActionTrigger,
onActionClose,
]);
// Handle keydown // Handle keydown
const handleKeyDown = useCallback( const handleKeyDown = useCallback(

View file

@ -395,7 +395,10 @@ const defaultComponents = memoizeMarkdownComponents({
if (!isCodeBlock) { if (!isCodeBlock) {
return ( return (
<code <code
className={cn("aui-md-inline-code rounded-md border bg-muted px-1.5 py-0.5 font-mono text-[0.9em] font-normal", className)} className={cn(
"aui-md-inline-code rounded-md border bg-muted px-1.5 py-0.5 font-mono text-[0.9em] font-normal",
className
)}
{...props} {...props}
> >
{children} {children}

View file

@ -61,11 +61,11 @@ import {
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { UserMessage } from "@/components/assistant-ui/user-message"; import { UserMessage } from "@/components/assistant-ui/user-message";
import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/components/layout/ui/sidebar/SidebarSlideOutPanel"; import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/components/layout/ui/sidebar/SidebarSlideOutPanel";
import { PromptPicker, type PromptPickerRef } from "@/components/new-chat/prompt-picker";
import { import {
DocumentMentionPicker, DocumentMentionPicker,
type DocumentMentionPickerRef, type DocumentMentionPickerRef,
} from "@/components/new-chat/document-mention-picker"; } 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 { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer"; import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
@ -356,7 +356,9 @@ const Composer: FC = () => {
const submitCleanupRef = useRef<(() => void) | null>(null); const submitCleanupRef = useRef<(() => void) | null>(null);
useEffect(() => { useEffect(() => {
return () => { submitCleanupRef.current?.(); }; return () => {
submitCleanupRef.current?.();
};
}, []); }, []);
const [clipboardInitialText, setClipboardInitialText] = useState<string | undefined>(); const [clipboardInitialText, setClipboardInitialText] = useState<string | undefined>();
@ -498,7 +500,9 @@ const Composer: FC = () => {
} }
const finalPrompt = action.prompt.includes("{selection}") const finalPrompt = action.prompt.includes("{selection}")
? action.prompt.replace("{selection}", () => userText) ? 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().setText(finalPrompt);
aui.composer().send(); aui.composer().send();
editorRef.current?.clear(); editorRef.current?.clear();
@ -590,9 +594,7 @@ const Composer: FC = () => {
if (!showDocumentPopover && !showPromptPicker) { if (!showDocumentPopover && !showPromptPicker) {
if (clipboardInitialText) { if (clipboardInitialText) {
const userText = editorRef.current?.getText() ?? ""; const userText = editorRef.current?.getText() ?? "";
const combined = userText const combined = userText ? `${userText}\n\n${clipboardInitialText}` : clipboardInitialText;
? `${userText}\n\n${clipboardInitialText}`
: clipboardInitialText;
aui.composer().setText(combined); aui.composer().setText(combined);
setClipboardInitialText(undefined); setClipboardInitialText(undefined);
} }
@ -706,7 +708,7 @@ const Composer: FC = () => {
return ( return (
<ComposerPrimitive.Root <ComposerPrimitive.Root
className="aui-composer-root relative flex w-full flex-col gap-2" className="aui-composer-root relative flex w-full flex-col gap-2"
style={(showPromptPicker && clipboardInitialText) ? { marginBottom: 220 } : undefined} style={showPromptPicker && clipboardInitialText ? { marginBottom: 220 } : undefined}
> >
<ChatSessionStatus <ChatSessionStatus
isAiResponding={isAiResponding} isAiResponding={isAiResponding}
@ -714,7 +716,10 @@ const Composer: FC = () => {
currentUserId={currentUser?.id ?? null} currentUserId={currentUser?.id ?? null}
members={members ?? []} members={members ?? []}
/> />
<div ref={composerBoxRef} className="aui-composer-attachment-dropzone flex w-full flex-col overflow-hidden rounded-2xl border-input bg-muted pt-2 outline-none transition-shadow"> <div
ref={composerBoxRef}
className="aui-composer-attachment-dropzone flex w-full flex-col overflow-hidden rounded-2xl border-input bg-muted pt-2 outline-none transition-shadow"
>
{clipboardInitialText && ( {clipboardInitialText && (
<ClipboardChip <ClipboardChip
text={clipboardInitialText} text={clipboardInitialText}
@ -777,10 +782,11 @@ const Composer: FC = () => {
position: "fixed", position: "fixed",
...(clipboardInitialText && composerBoxRef.current ...(clipboardInitialText && composerBoxRef.current
? { top: `${composerBoxRef.current.getBoundingClientRect().bottom + 8}px` } ? { top: `${composerBoxRef.current.getBoundingClientRect().bottom + 8}px` }
: { bottom: editorContainerRef.current : {
bottom: editorContainerRef.current
? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 8}px` ? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 8}px`
: "200px" } : "200px",
), }),
left: editorContainerRef.current left: editorContainerRef.current
? `${editorContainerRef.current.getBoundingClientRect().left}px` ? `${editorContainerRef.current.getBoundingClientRect().left}px`
: "50%", : "50%",

View file

@ -240,7 +240,9 @@ export function FolderTreeView({
return ( return (
<div className="flex flex-1 flex-col items-center justify-center gap-1 px-4 py-12 text-muted-foreground"> <div className="flex flex-1 flex-col items-center justify-center gap-1 px-4 py-12 text-muted-foreground">
<p className="text-sm font-medium">No documents found</p> <p className="text-sm font-medium">No documents found</p>
<p className="text-xs text-muted-foreground/70">Use the upload button or connect a source above</p> <p className="text-xs text-muted-foreground/70">
Use the upload button or connect a source above
</p>
</div> </div>
); );
} }

View file

@ -5,8 +5,8 @@ import { AlertCircle, XIcon } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom"; import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { PlateEditor } from "@/components/editor/plate-editor"; import { PlateEditor } from "@/components/editor/plate-editor";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer"; import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";

View file

@ -20,7 +20,12 @@ import {
teamDialogAtom, teamDialogAtom,
userSettingsDialogAtom, userSettingsDialogAtom,
} from "@/atoms/settings/settings-dialog.atoms"; } from "@/atoms/settings/settings-dialog.atoms";
import { removeChatTabAtom, resetTabsAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom"; import {
removeChatTabAtom,
resetTabsAtom,
syncChatTabAtom,
type Tab,
} from "@/atoms/tabs/tabs.atom";
import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { MorePagesDialog } from "@/components/settings/more-pages-dialog"; import { MorePagesDialog } from "@/components/settings/more-pages-dialog";
import { SearchSpaceSettingsDialog } from "@/components/settings/search-space-settings-dialog"; import { SearchSpaceSettingsDialog } from "@/components/settings/search-space-settings-dialog";
@ -846,7 +851,9 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
disabled={isRenamingChat || !newChatTitle.trim()} disabled={isRenamingChat || !newChatTitle.trim()}
className="relative" className="relative"
> >
<span className={isRenamingChat ? "opacity-0" : ""}>{tSidebar("rename") || "Rename"}</span> <span className={isRenamingChat ? "opacity-0" : ""}>
{tSidebar("rename") || "Rename"}
</span>
{isRenamingChat && ( {isRenamingChat && (
<span className="absolute h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" /> <span className="absolute h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
)} )}

View file

@ -2,8 +2,8 @@
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { hitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom"; import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { hitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { reportPanelAtom } from "@/atoms/chat/report-panel.atom"; import { reportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms"; import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
import { editorPanelAtom } from "@/atoms/editor/editor-panel.atom"; import { editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
@ -37,7 +37,8 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
const reportOpen = reportState.isOpen && !!reportState.reportId; const reportOpen = reportState.isOpen && !!reportState.reportId;
const editorOpen = editorState.isOpen && !!editorState.documentId; const editorOpen = editorState.isOpen && !!editorState.documentId;
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave; const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
const showExpandButton = !isMobile && collapsed && (documentsOpen || reportOpen || editorOpen || hitlEditOpen); const showExpandButton =
!isMobile && collapsed && (documentsOpen || reportOpen || editorOpen || hitlEditOpen);
const hasTabBar = tabs.length > 1; const hasTabBar = tabs.length > 1;
const currentThreadState = useAtomValue(currentThreadAtom); const currentThreadState = useAtomValue(currentThreadAtom);
@ -71,7 +72,9 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
</div> </div>
{/* Right side - Actions */} {/* Right side - Actions */}
<div className={cn("ml-auto flex items-center gap-2", showExpandButton && !hasTabBar && "mr-10")}> <div
className={cn("ml-auto flex items-center gap-2", showExpandButton && !hasTabBar && "mr-10")}
>
{hasThread && ( {hasThread && (
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} /> <ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />
)} )}

View file

@ -1,8 +1,8 @@
"use client"; "use client";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useSetAtom } from "jotai";
import { format } from "date-fns"; import { format } from "date-fns";
import { useSetAtom } from "jotai";
import { import {
ArchiveIcon, ArchiveIcon,
ChevronLeft, ChevronLeft,
@ -19,6 +19,7 @@ import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { removeChatTabAtom } from "@/atoms/tabs/tabs.atom";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/animated-tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/animated-tabs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -42,7 +43,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useLongPress } from "@/hooks/use-long-press"; import { useLongPress } from "@/hooks/use-long-press";
import { useIsMobile } from "@/hooks/use-mobile"; import { useIsMobile } from "@/hooks/use-mobile";
import { removeChatTabAtom } from "@/atoms/tabs/tabs.atom";
import { import {
deleteThread, deleteThread,
fetchThreads, fetchThreads,

View file

@ -1,8 +1,8 @@
"use client"; "use client";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useSetAtom } from "jotai";
import { format } from "date-fns"; import { format } from "date-fns";
import { useSetAtom } from "jotai";
import { import {
ArchiveIcon, ArchiveIcon,
ChevronLeft, ChevronLeft,
@ -19,6 +19,7 @@ import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { removeChatTabAtom } from "@/atoms/tabs/tabs.atom";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/animated-tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/animated-tabs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -42,7 +43,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useLongPress } from "@/hooks/use-long-press"; import { useLongPress } from "@/hooks/use-long-press";
import { useIsMobile } from "@/hooks/use-mobile"; import { useIsMobile } from "@/hooks/use-mobile";
import { removeChatTabAtom } from "@/atoms/tabs/tabs.atom";
import { import {
deleteThread, deleteThread,
fetchThreads, fetchThreads,

View file

@ -12,10 +12,10 @@ import { sidebarSelectedDocumentsAtom } from "@/atoms/chat/mentioned-documents.a
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms"; import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms"; import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms"; import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { expandedFolderIdsAtom } from "@/atoms/documents/folder.atoms"; import { expandedFolderIdsAtom } from "@/atoms/documents/folder.atoms";
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
import { agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms"; import { agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms";
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog"; import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog";
import type { DocumentNodeDoc } from "@/components/documents/DocumentNode"; import type { DocumentNodeDoc } from "@/components/documents/DocumentNode";
import type { FolderDisplay } from "@/components/documents/FolderNode"; import type { FolderDisplay } from "@/components/documents/FolderNode";

View file

@ -58,7 +58,12 @@ export function TabBar({ onTabSwitch, onNewChat, className }: TabBarProps) {
if (tabs.length <= 1) return null; if (tabs.length <= 1) return null;
return ( return (
<div className={cn("flex h-12 items-stretch shrink-0 border-b border-border/35 bg-main-panel", className)}> <div
className={cn(
"flex h-12 items-stretch shrink-0 border-b border-border/35 bg-main-panel",
className
)}
>
<div <div
ref={scrollRef} ref={scrollRef}
className="flex h-full items-stretch flex-1 overflow-x-auto overflow-y-hidden scrollbar-hide [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden" className="flex h-full items-stretch flex-1 overflow-x-auto overflow-y-hidden scrollbar-hide [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"

View file

@ -1,14 +1,14 @@
"use client"; "use client";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { ImageConfigDialog } from "@/components/shared/image-config-dialog";
import { ModelConfigDialog } from "@/components/shared/model-config-dialog";
import type { import type {
GlobalImageGenConfig, GlobalImageGenConfig,
GlobalNewLLMConfig, GlobalNewLLMConfig,
ImageGenerationConfig, ImageGenerationConfig,
NewLLMConfigPublic, NewLLMConfigPublic,
} from "@/contracts/types/new-llm-config.types"; } from "@/contracts/types/new-llm-config.types";
import { ImageConfigDialog } from "@/components/shared/image-config-dialog";
import { ModelConfigDialog } from "@/components/shared/model-config-dialog";
import { ModelSelector } from "./model-selector"; import { ModelSelector } from "./model-selector";
interface ChatHeaderProps { interface ChatHeaderProps {

View file

@ -1,5 +1,6 @@
"use client"; "use client";
import { useSetAtom } from "jotai";
import { import {
BookOpen, BookOpen,
Check, Check,
@ -8,11 +9,10 @@ import {
List, List,
Minimize2, Minimize2,
PenLine, PenLine,
Plus,
Search, Search,
Zap, Zap,
Plus,
} from "lucide-react"; } from "lucide-react";
import { useSetAtom } from "jotai";
import { import {
forwardRef, forwardRef,
useCallback, useCallback,
@ -53,19 +53,71 @@ const ICONS: Record<string, React.ReactNode> = {
zap: <Zap className="size-3.5" />, zap: <Zap className="size-3.5" />,
}; };
const DEFAULT_ACTIONS: { name: string; prompt: string; mode: "transform" | "explore"; icon: string }[] = [ const DEFAULT_ACTIONS: {
{ name: "Fix grammar", prompt: "Fix the grammar and spelling in the following text. Return only the corrected text, nothing else.\n\n{selection}", mode: "transform", icon: "check" }, name: string;
{ name: "Make shorter", prompt: "Make the following text more concise while preserving its meaning. Return only the shortened text, nothing else.\n\n{selection}", mode: "transform", icon: "minimize" }, prompt: string;
{ name: "Translate", prompt: "Translate the following text to English. If it is already in English, translate it to French. Return only the translation, nothing else.\n\n{selection}", mode: "transform", icon: "languages" }, mode: "transform" | "explore";
{ name: "Rewrite", prompt: "Rewrite the following text to improve clarity and readability. Return only the rewritten text, nothing else.\n\n{selection}", mode: "transform", icon: "pen-line" }, icon: string;
{ name: "Summarize", prompt: "Summarize the following text concisely. Return only the summary, nothing else.\n\n{selection}", mode: "transform", icon: "list" }, }[] = [
{ name: "Explain", prompt: "Explain the following text in simple terms:\n\n{selection}", mode: "explore", icon: "book-open" }, {
{ name: "Ask my knowledge base", prompt: "Search my knowledge base for information related to:\n\n{selection}", mode: "explore", icon: "search" }, name: "Fix grammar",
{ name: "Look up on the web", prompt: "Search the web for information about:\n\n{selection}", mode: "explore", icon: "globe" }, prompt:
"Fix the grammar and spelling in the following text. Return only the corrected text, nothing else.\n\n{selection}",
mode: "transform",
icon: "check",
},
{
name: "Make shorter",
prompt:
"Make the following text more concise while preserving its meaning. Return only the shortened text, nothing else.\n\n{selection}",
mode: "transform",
icon: "minimize",
},
{
name: "Translate",
prompt:
"Translate the following text to English. If it is already in English, translate it to French. Return only the translation, nothing else.\n\n{selection}",
mode: "transform",
icon: "languages",
},
{
name: "Rewrite",
prompt:
"Rewrite the following text to improve clarity and readability. Return only the rewritten text, nothing else.\n\n{selection}",
mode: "transform",
icon: "pen-line",
},
{
name: "Summarize",
prompt:
"Summarize the following text concisely. Return only the summary, nothing else.\n\n{selection}",
mode: "transform",
icon: "list",
},
{
name: "Explain",
prompt: "Explain the following text in simple terms:\n\n{selection}",
mode: "explore",
icon: "book-open",
},
{
name: "Ask my knowledge base",
prompt: "Search my knowledge base for information related to:\n\n{selection}",
mode: "explore",
icon: "search",
},
{
name: "Look up on the web",
prompt: "Search the web for information about:\n\n{selection}",
mode: "explore",
icon: "globe",
},
]; ];
export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>( export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(function PromptPicker(
function PromptPicker({ onSelect, onDone, externalSearch = "", containerStyle }, ref) { { onSelect, onDone, externalSearch = "", containerStyle },
ref
) {
const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom); const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom);
const [highlightedIndex, setHighlightedIndex] = useState(0); const [highlightedIndex, setHighlightedIndex] = useState(0);
const [customPrompts, setCustomPrompts] = useState<PromptRead[]>([]); const [customPrompts, setCustomPrompts] = useState<PromptRead[]>([]);
@ -74,7 +126,10 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map()); const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
useEffect(() => { useEffect(() => {
promptsApiService.list().then(setCustomPrompts).catch(() => {}); promptsApiService
.list()
.then(setCustomPrompts)
.catch(() => {});
}, []); }, []);
const allActions = useMemo(() => { const allActions = useMemo(() => {
@ -89,9 +144,7 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!externalSearch) return allActions; if (!externalSearch) return allActions;
return allActions.filter((a) => return allActions.filter((a) => a.name.toLowerCase().includes(externalSearch.toLowerCase()));
a.name.toLowerCase().includes(externalSearch.toLowerCase())
);
}, [allActions, externalSearch]); }, [allActions, externalSearch]);
// Reset highlight when results change // Reset highlight when results change
@ -174,14 +227,14 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(
index === highlightedIndex ? "bg-accent" : "hover:bg-accent/50" index === highlightedIndex ? "bg-accent" : "hover:bg-accent/50"
)} )}
> >
<span className="text-muted-foreground">{ICONS[action.icon] ?? <Zap className="size-3.5" />}</span> <span className="text-muted-foreground">
{ICONS[action.icon] ?? <Zap className="size-3.5" />}
</span>
<span className="truncate">{action.name}</span> <span className="truncate">{action.name}</span>
</button> </button>
))} ))}
{customFiltered.length > 0 && ( {customFiltered.length > 0 && <div className="my-1 h-px bg-border mx-2" />}
<div className="my-1 h-px bg-border mx-2" />
)}
{customFiltered.map((action, i) => { {customFiltered.map((action, i) => {
const index = defaultFiltered.length + i; const index = defaultFiltered.length + i;
@ -200,7 +253,9 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(
index === highlightedIndex ? "bg-accent" : "hover:bg-accent/50" index === highlightedIndex ? "bg-accent" : "hover:bg-accent/50"
)} )}
> >
<span className="text-muted-foreground"><Zap className="size-3.5" /></span> <span className="text-muted-foreground">
<Zap className="size-3.5" />
</span>
<span className="truncate">{action.name}</span> <span className="truncate">{action.name}</span>
</button> </button>
); );
@ -221,5 +276,4 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(
</div> </div>
</div> </div>
); );
} });
);

View file

@ -1,24 +1,15 @@
"use client"; "use client";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { import { AlertCircle, Edit3, Info, Plus, RefreshCw, Trash2, Wand2 } from "lucide-react";
AlertCircle,
Edit3,
Info,
Plus,
RefreshCw,
Trash2,
Wand2,
} from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { import { deleteImageGenConfigMutationAtom } from "@/atoms/image-gen-config/image-gen-config-mutation.atoms";
deleteImageGenConfigMutationAtom,
} from "@/atoms/image-gen-config/image-gen-config-mutation.atoms";
import { import {
globalImageGenConfigsAtom, globalImageGenConfigsAtom,
imageGenConfigsAtom, imageGenConfigsAtom,
} from "@/atoms/image-gen-config/image-gen-config-query.atoms"; } from "@/atoms/image-gen-config/image-gen-config-query.atoms";
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms"; import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
import { ImageConfigDialog } from "@/components/shared/image-config-dialog";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { import {
AlertDialog, AlertDialog,
@ -40,7 +31,6 @@ import type { ImageGenerationConfig } from "@/contracts/types/new-llm-config.typ
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
import { getProviderIcon } from "@/lib/provider-icons"; import { getProviderIcon } from "@/lib/provider-icons";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ImageConfigDialog } from "@/components/shared/image-config-dialog";
interface ImageModelManagerProps { interface ImageModelManagerProps {
searchSpaceId: number; searchSpaceId: number;
@ -196,7 +186,16 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
<Alert className="bg-muted/50 py-3"> <Alert className="bg-muted/50 py-3">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" /> <Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm"> <AlertDescription className="text-xs md:text-sm">
<p><span className="font-medium">{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length} global image {globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length === 1 ? "model" : "models"}</span> available from your administrator. Use the model selector to view and select them.</p> <p>
<span className="font-medium">
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length}{" "}
global image{" "}
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length === 1
? "model"
: "models"}
</span>{" "}
available from your administrator. Use the model selector to view and select them.
</p>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
@ -400,9 +399,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
> >
<AlertDialogContent className="select-none"> <AlertDialogContent className="select-none">
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle> <AlertDialogTitle>Delete Image Model</AlertDialogTitle>
Delete Image Model
</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Are you sure you want to delete{" "} Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">{configToDelete?.name}</span>? <span className="font-semibold text-foreground">{configToDelete?.name}</span>?

View file

@ -14,9 +14,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms"; import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
import { import { deleteNewLLMConfigMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
deleteNewLLMConfigMutationAtom,
} from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
import { import {
globalNewLLMConfigsAtom, globalNewLLMConfigsAtom,
newLLMConfigsAtom, newLLMConfigsAtom,
@ -203,7 +201,12 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
<Alert className="bg-muted/50 py-3"> <Alert className="bg-muted/50 py-3">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" /> <Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm"> <AlertDescription className="text-xs md:text-sm">
<p><span className="font-medium">{globalConfigs.length} global {globalConfigs.length === 1 ? "model" : "models"}</span> available from your administrator. Use the model selector to view and select them.</p> <p>
<span className="font-medium">
{globalConfigs.length} global {globalConfigs.length === 1 ? "model" : "models"}
</span>{" "}
available from your administrator. Use the model selector to view and select them.
</p>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
@ -433,9 +436,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
> >
<AlertDialogContent className="select-none"> <AlertDialogContent className="select-none">
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle> <AlertDialogTitle>Delete LLM Model</AlertDialogTitle>
Delete LLM Model
</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Are you sure you want to delete{" "} Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">{configToDelete?.name}</span>? This <span className="font-semibold text-foreground">{configToDelete?.name}</span>? This

View file

@ -217,9 +217,7 @@ export function ImageConfigDialog({
</div> </div>
<p className="text-sm text-muted-foreground">{getSubtitle()}</p> <p className="text-sm text-muted-foreground">{getSubtitle()}</p>
{config && mode !== "create" && ( {config && mode !== "create" && (
<p className="text-xs font-mono text-muted-foreground/70"> <p className="text-xs font-mono text-muted-foreground/70">{config.model_name}</p>
{config.model_name}
</p>
)} )}
</div> </div>
</div> </div>
@ -294,9 +292,7 @@ export function ImageConfigDialog({
<Input <Input
placeholder="Optional description" placeholder="Optional description"
value={formData.description} value={formData.description}
onChange={(e) => onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
setFormData((p) => ({ ...p, description: e.target.value }))
}
/> />
</div> </div>
@ -337,17 +333,12 @@ export function ImageConfigDialog({
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent className="w-full p-0" align="start">
className="w-full p-0"
align="start"
>
<Command className="bg-transparent"> <Command className="bg-transparent">
<CommandInput <CommandInput
placeholder="Search or type model..." placeholder="Search or type model..."
value={formData.model_name} value={formData.model_name}
onValueChange={(val) => onValueChange={(val) => setFormData((p) => ({ ...p, model_name: val }))}
setFormData((p) => ({ ...p, model_name: val }))
}
/> />
<CommandList> <CommandList>
<CommandEmpty> <CommandEmpty>
@ -368,9 +359,7 @@ export function ImageConfigDialog({
<Check <Check
className={cn( className={cn(
"mr-2 h-4 w-4", "mr-2 h-4 w-4",
formData.model_name === m.value formData.model_name === m.value ? "opacity-100" : "opacity-0"
? "opacity-100"
: "opacity-0"
)} )}
/> />
<span className="font-mono text-sm">{m.value}</span> <span className="font-mono text-sm">{m.value}</span>
@ -388,9 +377,7 @@ export function ImageConfigDialog({
<Input <Input
placeholder="e.g., dall-e-3" placeholder="e.g., dall-e-3"
value={formData.model_name} value={formData.model_name}
onChange={(e) => onChange={(e) => setFormData((p) => ({ ...p, model_name: e.target.value }))}
setFormData((p) => ({ ...p, model_name: e.target.value }))
}
/> />
)} )}
</div> </div>
@ -420,9 +407,7 @@ export function ImageConfigDialog({
<Input <Input
placeholder="2024-02-15-preview" placeholder="2024-02-15-preview"
value={formData.api_version} value={formData.api_version}
onChange={(e) => onChange={(e) => setFormData((p) => ({ ...p, api_version: e.target.value }))}
setFormData((p) => ({ ...p, api_version: e.target.value }))
}
/> />
</div> </div>
)} )}

View file

@ -4,7 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { Check, ChevronDown, ChevronsUpDown } from "lucide-react"; import { Check, ChevronDown, ChevronsUpDown } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useForm, type Resolver } from "react-hook-form"; import { type Resolver, useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { import {
defaultSystemInstructionsAtom, defaultSystemInstructionsAtom,
@ -228,11 +228,7 @@ export function LLMConfigForm({
<FormItem> <FormItem>
<FormLabel className="text-xs sm:text-sm">Custom Provider Name</FormLabel> <FormLabel className="text-xs sm:text-sm">Custom Provider Name</FormLabel>
<FormControl> <FormControl>
<Input <Input placeholder="my-custom-provider" {...field} value={field.value ?? ""} />
placeholder="my-custom-provider"
{...field}
value={field.value ?? ""}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>

View file

@ -167,9 +167,7 @@ export function ModelConfigDialog({
</div> </div>
<p className="text-sm text-muted-foreground">{getSubtitle()}</p> <p className="text-sm text-muted-foreground">{getSubtitle()}</p>
{config && mode !== "create" && ( {config && mode !== "create" && (
<p className="text-xs font-mono text-muted-foreground/70"> <p className="text-xs font-mono text-muted-foreground/70">{config.model_name}</p>
{config.model_name}
</p>
)} )}
</div> </div>
</div> </div>

View file

@ -1,8 +1,8 @@
"use client"; "use client";
export { cn } from "@/lib/utils";
export { export {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
export { cn } from "@/lib/utils";

View file

@ -1,27 +1,12 @@
"use client"; "use client";
import * as React from "react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { import { Code2, Database, ExternalLink, File, FileText, Globe, Newspaper } from "lucide-react";
FileText, import * as React from "react";
Globe, import { openSafeNavigationHref, resolveSafeNavigationHref } from "../shared/media";
Code2,
Newspaper,
Database,
File,
ExternalLink,
} from "lucide-react";
import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter"; import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter";
import { Citation } from "./citation"; import { Citation } from "./citation";
import type { import type { CitationType, CitationVariant, SerializableCitation } from "./schema";
SerializableCitation,
CitationType,
CitationVariant,
} from "./schema";
import {
openSafeNavigationHref,
resolveSafeNavigationHref,
} from "../shared/media";
const TYPE_ICONS: Record<CitationType, LucideIcon> = { const TYPE_ICONS: Record<CitationType, LucideIcon> = {
webpage: Globe, webpage: Globe,
@ -64,7 +49,7 @@ function useHoverPopover(delay = 100) {
if (timeoutRef.current) clearTimeout(timeoutRef.current); if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setOpen(false), delay); timeoutRef.current = setTimeout(() => setOpen(false), delay);
}, },
[delay], [delay]
); );
React.useEffect(() => { React.useEffect(() => {
@ -94,27 +79,15 @@ export interface CitationListProps {
} }
export function CitationList(props: CitationListProps) { export function CitationList(props: CitationListProps) {
const { const { id, citations, variant = "default", maxVisible, className, onNavigate } = props;
id,
citations,
variant = "default",
maxVisible,
className,
onNavigate,
} = props;
const shouldTruncate = const shouldTruncate = maxVisible !== undefined && citations.length > maxVisible;
maxVisible !== undefined && citations.length > maxVisible; const visibleCitations = shouldTruncate ? citations.slice(0, maxVisible) : citations;
const visibleCitations = shouldTruncate
? citations.slice(0, maxVisible)
: citations;
const overflowCitations = shouldTruncate ? citations.slice(maxVisible) : []; const overflowCitations = shouldTruncate ? citations.slice(maxVisible) : [];
const overflowCount = overflowCitations.length; const overflowCount = overflowCitations.length;
const wrapperClass = const wrapperClass =
variant === "inline" variant === "inline" ? "flex flex-wrap items-center gap-1.5" : "flex flex-col gap-2";
? "flex flex-wrap items-center gap-1.5"
: "flex flex-col gap-2";
// Stacked variant: overlapping favicons with popover // Stacked variant: overlapping favicons with popover
if (variant === "stacked") { if (variant === "stacked") {
@ -136,12 +109,7 @@ export function CitationList(props: CitationListProps) {
data-slot="citation-list" data-slot="citation-list"
> >
{visibleCitations.map((citation) => ( {visibleCitations.map((citation) => (
<Citation <Citation key={citation.id} {...citation} variant="default" onNavigate={onNavigate} />
key={citation.id}
{...citation}
variant="default"
onNavigate={onNavigate}
/>
))} ))}
{shouldTruncate && ( {shouldTruncate && (
<OverflowIndicator <OverflowIndicator
@ -162,12 +130,7 @@ export function CitationList(props: CitationListProps) {
data-slot="citation-list" data-slot="citation-list"
> >
{visibleCitations.map((citation) => ( {visibleCitations.map((citation) => (
<Citation <Citation key={citation.id} {...citation} variant={variant} onNavigate={onNavigate} />
key={citation.id}
{...citation}
variant={variant}
onNavigate={onNavigate}
/>
))} ))}
{shouldTruncate && ( {shouldTruncate && (
<OverflowIndicator <OverflowIndicator
@ -188,12 +151,7 @@ interface OverflowIndicatorProps {
onNavigate?: (href: string, citation: SerializableCitation) => void; onNavigate?: (href: string, citation: SerializableCitation) => void;
} }
function OverflowIndicator({ function OverflowIndicator({ citations, count, variant, onNavigate }: OverflowIndicatorProps) {
citations,
count,
variant,
onNavigate,
}: OverflowIndicatorProps) {
const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover(); const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover();
const handleClick = (citation: SerializableCitation) => { const handleClick = (citation: SerializableCitation) => {
@ -209,11 +167,7 @@ function OverflowIndicator({
const popoverContent = ( const popoverContent = (
<div className="flex max-h-72 flex-col overflow-y-auto"> <div className="flex max-h-72 flex-col overflow-y-auto">
{citations.map((citation) => ( {citations.map((citation) => (
<OverflowItem <OverflowItem key={citation.id} citation={citation} onClick={() => handleClick(citation)} />
key={citation.id}
citation={citation}
onClick={() => handleClick(citation)}
/>
))} ))}
</div> </div>
); );
@ -231,7 +185,7 @@ function OverflowIndicator({
"bg-muted/60 text-sm tabular-nums", "bg-muted/60 text-sm tabular-nums",
"transition-colors duration-150", "transition-colors duration-150",
"hover:bg-muted", "hover:bg-muted",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none", "focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
)} )}
> >
<span className="text-muted-foreground">+{count} more</span> <span className="text-muted-foreground">+{count} more</span>
@ -264,12 +218,10 @@ function OverflowIndicator({
"border-border bg-card border border-dashed", "border-border bg-card border border-dashed",
"transition-colors duration-150", "transition-colors duration-150",
"hover:border-foreground/25 hover:bg-muted/50", "hover:border-foreground/25 hover:bg-muted/50",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none", "focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
)} )}
> >
<span className="text-muted-foreground text-sm tabular-nums"> <span className="text-muted-foreground text-sm tabular-nums">+{count} more sources</span>
+{count} more sources
</span>
</button> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
@ -311,18 +263,13 @@ function OverflowItem({ citation, onClick }: OverflowItemProps) {
className="bg-muted size-4 shrink-0 rounded object-cover" className="bg-muted size-4 shrink-0 rounded object-cover"
/> />
) : ( ) : (
<TypeIcon <TypeIcon className="text-muted-foreground size-4 shrink-0" aria-hidden="true" />
className="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
/>
)} )}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="group-hover:decoration-foreground/30 truncate text-sm font-medium group-hover:underline group-hover:underline-offset-2"> <p className="group-hover:decoration-foreground/30 truncate text-sm font-medium group-hover:underline group-hover:underline-offset-2">
{citation.title} {citation.title}
</p> </p>
<p className="text-muted-foreground truncate text-xs"> <p className="text-muted-foreground truncate text-xs">{citation.domain}</p>
{citation.domain}
</p>
</div> </div>
<ExternalLink className="text-muted-foreground mt-0.5 size-3.5 shrink-0 self-start opacity-0 transition-opacity group-hover:opacity-100" /> <ExternalLink className="text-muted-foreground mt-0.5 size-3.5 shrink-0 self-start opacity-0 transition-opacity group-hover:opacity-100" />
</button> </button>
@ -336,20 +283,9 @@ interface StackedCitationsProps {
onNavigate?: (href: string, citation: SerializableCitation) => void; onNavigate?: (href: string, citation: SerializableCitation) => void;
} }
function StackedCitations({ function StackedCitations({ id, citations, className, onNavigate }: StackedCitationsProps) {
id, const { open, setOpen, containerRef, handleMouseEnter, handleMouseLeave, handleBlur } =
citations, useHoverPopover();
className,
onNavigate,
}: StackedCitationsProps) {
const {
open,
setOpen,
containerRef,
handleMouseEnter,
handleMouseLeave,
handleBlur,
} = useHoverPopover();
const maxIcons = 4; const maxIcons = 4;
const visibleCitations = citations.slice(0, maxIcons); const visibleCitations = citations.slice(0, maxIcons);
const remainingCount = Math.max(0, citations.length - maxIcons); const remainingCount = Math.max(0, citations.length - maxIcons);
@ -387,19 +323,18 @@ function StackedCitations({
"transition-colors duration-150", "transition-colors duration-150",
"hover:bg-muted/70", "hover:bg-muted/70",
"focus-visible:ring-ring focus-visible:ring-2", "focus-visible:ring-ring focus-visible:ring-2",
className, className
)} )}
> >
<div className="flex items-center"> <div className="flex items-center">
{visibleCitations.map((citation, index) => { {visibleCitations.map((citation, index) => {
const TypeIcon = const TypeIcon = TYPE_ICONS[citation.type ?? "webpage"] ?? Globe;
TYPE_ICONS[citation.type ?? "webpage"] ?? Globe;
return ( return (
<div <div
key={citation.id} key={citation.id}
className={cn( className={cn(
"border-border bg-background dark:border-foreground/20 relative flex size-6 items-center justify-center rounded-full border shadow-xs", "border-border bg-background dark:border-foreground/20 relative flex size-6 items-center justify-center rounded-full border shadow-xs",
index > 0 && "-ml-2", index > 0 && "-ml-2"
)} )}
style={{ zIndex: maxIcons - index }} style={{ zIndex: maxIcons - index }}
> >
@ -414,10 +349,7 @@ function StackedCitations({
className="size-4.5 rounded-full object-cover" className="size-4.5 rounded-full object-cover"
/> />
) : ( ) : (
<TypeIcon <TypeIcon className="text-muted-foreground size-3" aria-hidden="true" />
className="text-muted-foreground size-3"
aria-hidden="true"
/>
)} )}
</div> </div>
); );

View file

@ -1,24 +1,11 @@
"use client"; "use client";
import * as React from "react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { import { Code2, Database, ExternalLink, File, FileText, Globe, Newspaper } from "lucide-react";
FileText, import * as React from "react";
Globe,
Code2,
Newspaper,
Database,
File,
ExternalLink,
} from "lucide-react";
import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter";
import { openSafeNavigationHref, sanitizeHref } from "../shared/media"; import { openSafeNavigationHref, sanitizeHref } from "../shared/media";
import type { import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter";
SerializableCitation, import type { CitationType, CitationVariant, SerializableCitation } from "./schema";
CitationType,
CitationVariant,
} from "./schema";
const FALLBACK_LOCALE = "en-US"; const FALLBACK_LOCALE = "en-US";
@ -161,7 +148,7 @@ export function Citation(props: CitationProps) {
"transition-colors duration-150", "transition-colors duration-150",
"hover:bg-muted", "hover:bg-muted",
"focus-visible:ring-ring focus-visible:ring-2", "focus-visible:ring-ring focus-visible:ring-2",
className, className
)} )}
> >
{iconElement} {iconElement}
@ -213,7 +200,7 @@ export function Citation(props: CitationProps) {
"cursor-pointer", "cursor-pointer",
"hover:border-foreground/25", "hover:border-foreground/25",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none", "focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
], ]
)} )}
onClick={sanitizedHref ? handleClick : undefined} onClick={sanitizedHref ? handleClick : undefined}
role={sanitizedHref ? "link" : undefined} role={sanitizedHref ? "link" : undefined}

View file

@ -1,9 +1,9 @@
export { Citation } from "./citation";
export type { CitationProps } from "./citation"; export type { CitationProps } from "./citation";
export { CitationList } from "./citation-list"; export { Citation } from "./citation";
export type { CitationListProps } from "./citation-list"; export type { CitationListProps } from "./citation-list";
export { CitationList } from "./citation-list";
export type { export type {
SerializableCitation,
CitationType, CitationType,
CitationVariant, CitationVariant,
SerializableCitation,
} from "./schema"; } from "./schema";

View file

@ -1,9 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { import { ToolUIIdSchema, ToolUIReceiptSchema, ToolUIRoleSchema } from "../shared/schema";
ToolUIIdSchema,
ToolUIReceiptSchema,
ToolUIRoleSchema,
} from "../shared/schema";
export const CitationTypeSchema = z.enum([ export const CitationTypeSchema = z.enum([
"webpage", "webpage",

View file

@ -17,7 +17,6 @@ export {
export { GeneratePodcastToolUI } from "./generate-podcast"; export { GeneratePodcastToolUI } from "./generate-podcast";
export { GenerateReportToolUI } from "./generate-report"; export { GenerateReportToolUI } from "./generate-report";
export { CreateGoogleDriveFileToolUI, DeleteGoogleDriveFileToolUI } from "./google-drive"; export { CreateGoogleDriveFileToolUI, DeleteGoogleDriveFileToolUI } from "./google-drive";
export { CreateOneDriveFileToolUI, DeleteOneDriveFileToolUI } from "./onedrive";
export { export {
Image, Image,
ImageErrorBoundary, ImageErrorBoundary,
@ -33,6 +32,7 @@ export {
UpdateLinearIssueToolUI, UpdateLinearIssueToolUI,
} from "./linear"; } from "./linear";
export { CreateNotionPageToolUI, DeleteNotionPageToolUI, UpdateNotionPageToolUI } from "./notion"; export { CreateNotionPageToolUI, DeleteNotionPageToolUI, UpdateNotionPageToolUI } from "./notion";
export { CreateOneDriveFileToolUI, DeleteOneDriveFileToolUI } from "./onedrive";
export { export {
Plan, Plan,
PlanErrorBoundary, PlanErrorBoundary,

View file

@ -270,9 +270,7 @@ function ApprovalCard({
)} )}
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground"> <p className="text-xs font-medium text-muted-foreground">File Type</p>
File Type
</p>
<Select value="docx" disabled> <Select value="docx" disabled>
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue /> <SelectValue />
@ -315,11 +313,25 @@ function ApprovalCard({
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 pt-3"> <div className="px-5 pt-3">
{(pendingEdits?.name ?? args.name) != null && ( {(pendingEdits?.name ?? args.name) != null && (
<p className="text-sm font-medium text-foreground">{String(pendingEdits?.name ?? args.name)}</p> <p className="text-sm font-medium text-foreground">
{String(pendingEdits?.name ?? args.name)}
</p>
)} )}
{(pendingEdits?.content ?? args.content) != null && ( {(pendingEdits?.content ?? args.content) != null && (
<div className="mt-2 max-h-[7rem] overflow-hidden text-sm" style={{ maskImage: "linear-gradient(to bottom, black 50%, transparent 100%)", WebkitMaskImage: "linear-gradient(to bottom, black 50%, transparent 100%)" }}> <div
<PlateEditor markdown={String(pendingEdits?.content ?? args.content)} readOnly preset="readonly" editorVariant="none" className="h-auto [&_[data-slate-editor]]:!min-h-0 [&_[data-slate-editor]>*:first-child]:!mt-0" /> className="mt-2 max-h-[7rem] overflow-hidden text-sm"
style={{
maskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
WebkitMaskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
}}
>
<PlateEditor
markdown={String(pendingEdits?.content ?? args.content)}
readOnly
preset="readonly"
editorVariant="none"
className="h-auto [&_[data-slate-editor]]:!min-h-0 [&_[data-slate-editor]>*:first-child]:!mt-0"
/>
</div> </div>
)} )}
</div> </div>
@ -329,12 +341,26 @@ function ApprovalCard({
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none"> <div className="px-5 py-4 flex items-center gap-2 select-none">
{allowedDecisions.includes("approve") && ( {allowedDecisions.includes("approve") && (
<Button size="sm" className="rounded-lg gap-1.5" onClick={handleApprove} disabled={!canApprove || isPanelOpen}> <Button
size="sm"
className="rounded-lg gap-1.5"
onClick={handleApprove}
disabled={!canApprove || isPanelOpen}
>
Approve <CornerDownLeftIcon className="size-3 opacity-60" /> Approve <CornerDownLeftIcon className="size-3 opacity-60" />
</Button> </Button>
)} )}
{allowedDecisions.includes("reject") && ( {allowedDecisions.includes("reject") && (
<Button size="sm" variant="ghost" className="rounded-lg text-muted-foreground" disabled={isPanelOpen} onClick={() => { setRejected(); onDecision({ type: "reject", message: "User rejected the action." }); }}> <Button
size="sm"
variant="ghost"
className="rounded-lg text-muted-foreground"
disabled={isPanelOpen}
onClick={() => {
setRejected();
onDecision({ type: "reject", message: "User rejected the action." });
}}
>
Reject Reject
</Button> </Button>
)} )}
@ -352,7 +378,9 @@ function ErrorCard({ result }: { result: ErrorResult }) {
<p className="text-sm font-semibold text-destructive">Failed to create OneDrive file</p> <p className="text-sm font-semibold text-destructive">Failed to create OneDrive file</p>
</div> </div>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4"><p className="text-sm text-muted-foreground">{result.message}</p></div> <div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div> </div>
); );
} }
@ -364,7 +392,9 @@ function AuthErrorCard({ result }: { result: AuthErrorResult }) {
<p className="text-sm font-semibold text-destructive">OneDrive authentication expired</p> <p className="text-sm font-semibold text-destructive">OneDrive authentication expired</p>
</div> </div>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4"><p className="text-sm text-muted-foreground">{result.message}</p></div> <div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div> </div>
); );
} }
@ -373,7 +403,9 @@ function SuccessCard({ result }: { result: SuccessResult }) {
return ( return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none"> <div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4"> <div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-foreground">{result.message || "OneDrive file created successfully"}</p> <p className="text-sm font-semibold text-foreground">
{result.message || "OneDrive file created successfully"}
</p>
</div> </div>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-2 text-xs"> <div className="px-5 py-4 space-y-2 text-xs">
@ -383,7 +415,14 @@ function SuccessCard({ result }: { result: SuccessResult }) {
</div> </div>
{result.web_url && ( {result.web_url && (
<div> <div>
<a href={result.web_url} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">Open in OneDrive</a> <a
href={result.web_url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Open in OneDrive
</a>
</div> </div>
)} )}
</div> </div>
@ -391,12 +430,31 @@ function SuccessCard({ result }: { result: SuccessResult }) {
); );
} }
export const CreateOneDriveFileToolUI = ({ args, result }: ToolCallMessagePartProps<{ name: string; content?: string }, CreateOneDriveFileResult>) => { export const CreateOneDriveFileToolUI = ({
args,
result,
}: ToolCallMessagePartProps<{ name: string; content?: string }, CreateOneDriveFileResult>) => {
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
return <ApprovalCard args={args} interruptData={result} onDecision={(decision) => { window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })); }} />; return (
<ApprovalCard
args={args}
interruptData={result}
onDecision={(decision) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
/>
);
} }
if (typeof result === "object" && result !== null && "status" in result && (result as { status: string }).status === "rejected") return null; if (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as { status: string }).status === "rejected"
)
return null;
if (isAuthErrorResult(result)) return <AuthErrorCard result={result} />; if (isAuthErrorResult(result)) return <AuthErrorCard result={result} />;
if (isErrorResult(result)) return <ErrorCard result={result} />; if (isErrorResult(result)) return <ErrorCard result={result} />;
return <SuccessCard result={result as SuccessResult} />; return <SuccessCard result={result as SuccessResult} />;

View file

@ -31,29 +31,76 @@ interface InterruptResult {
context?: { account?: OneDriveAccount; file?: OneDriveFile; error?: string }; context?: { account?: OneDriveAccount; file?: OneDriveFile; error?: string };
} }
interface SuccessResult { status: "success"; file_id: string; message?: string; deleted_from_kb?: boolean } interface SuccessResult {
interface ErrorResult { status: "error"; message: string } status: "success";
interface NotFoundResult { status: "not_found"; message: string } file_id: string;
interface AuthErrorResult { status: "auth_error"; message: string; connector_type?: string } message?: string;
deleted_from_kb?: boolean;
}
interface ErrorResult {
status: "error";
message: string;
}
interface NotFoundResult {
status: "not_found";
message: string;
}
interface AuthErrorResult {
status: "auth_error";
message: string;
connector_type?: string;
}
type DeleteOneDriveFileResult = InterruptResult | SuccessResult | ErrorResult | NotFoundResult | AuthErrorResult; type DeleteOneDriveFileResult =
| InterruptResult
| SuccessResult
| ErrorResult
| NotFoundResult
| AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult { function isInterruptResult(result: unknown): result is InterruptResult {
return typeof result === "object" && result !== null && "__interrupt__" in result && (result as InterruptResult).__interrupt__ === true; return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
} }
function isErrorResult(result: unknown): result is ErrorResult { function isErrorResult(result: unknown): result is ErrorResult {
return typeof result === "object" && result !== null && "status" in result && (result as ErrorResult).status === "error"; return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as ErrorResult).status === "error"
);
} }
function isNotFoundResult(result: unknown): result is NotFoundResult { function isNotFoundResult(result: unknown): result is NotFoundResult {
return typeof result === "object" && result !== null && "status" in result && (result as NotFoundResult).status === "not_found"; return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as NotFoundResult).status === "not_found"
);
} }
function isAuthErrorResult(result: unknown): result is AuthErrorResult { function isAuthErrorResult(result: unknown): result is AuthErrorResult {
return typeof result === "object" && result !== null && "status" in result && (result as AuthErrorResult).status === "auth_error"; return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as AuthErrorResult).status === "auth_error"
);
} }
function ApprovalCard({ interruptData, onDecision }: { function ApprovalCard({
interruptData,
onDecision,
}: {
interruptData: InterruptResult; interruptData: InterruptResult;
onDecision: (decision: { type: "approve" | "reject"; message?: string; edited_action?: { name: string; args: Record<string, unknown> } }) => void; onDecision: (decision: {
type: "approve" | "reject";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
}) { }) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData); const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [deleteFromKb, setDeleteFromKb] = useState(false); const [deleteFromKb, setDeleteFromKb] = useState(false);
@ -87,7 +134,11 @@ function ApprovalCard({ interruptData, onDecision }: {
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div> <div>
<p className="text-sm font-semibold text-foreground"> <p className="text-sm font-semibold text-foreground">
{phase === "rejected" ? "OneDrive File Deletion Rejected" : phase === "processing" || phase === "complete" ? "OneDrive File Deletion Approved" : "Delete OneDrive File"} {phase === "rejected"
? "OneDrive File Deletion Rejected"
: phase === "processing" || phase === "complete"
? "OneDrive File Deletion Approved"
: "Delete OneDrive File"}
</p> </p>
{phase === "processing" ? ( {phase === "processing" ? (
<TextShimmerLoader text="Trashing file" size="sm" /> <TextShimmerLoader text="Trashing file" size="sm" />
@ -96,7 +147,9 @@ function ApprovalCard({ interruptData, onDecision }: {
) : phase === "rejected" ? ( ) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">File deletion was cancelled</p> <p className="text-xs text-muted-foreground mt-0.5">File deletion was cancelled</p>
) : ( ) : (
<p className="text-xs text-muted-foreground mt-0.5">Requires your approval to proceed</p> <p className="text-xs text-muted-foreground mt-0.5">
Requires your approval to proceed
</p>
)} )}
</div> </div>
</div> </div>
@ -112,7 +165,9 @@ function ApprovalCard({ interruptData, onDecision }: {
{account && ( {account && (
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">OneDrive Account</p> <p className="text-xs font-medium text-muted-foreground">OneDrive Account</p>
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm">{account.name}</div> <div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm">
{account.name}
</div>
</div> </div>
)} )}
{file && ( {file && (
@ -121,7 +176,14 @@ function ApprovalCard({ interruptData, onDecision }: {
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm space-y-0.5"> <div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm space-y-0.5">
<div className="font-medium">{file.name}</div> <div className="font-medium">{file.name}</div>
{file.web_url && ( {file.web_url && (
<a href={file.web_url} target="_blank" rel="noopener noreferrer" className="text-xs text-primary hover:underline">Open in OneDrive</a> <a
href={file.web_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline"
>
Open in OneDrive
</a>
)} )}
</div> </div>
</div> </div>
@ -136,12 +198,21 @@ function ApprovalCard({ interruptData, onDecision }: {
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-3 select-none"> <div className="px-5 py-4 space-y-3 select-none">
<p className="text-xs text-muted-foreground">The file will be moved to the OneDrive recycle bin. You can restore it within 93 days.</p> <p className="text-xs text-muted-foreground">
The file will be moved to the OneDrive recycle bin. You can restore it within 93 days.
</p>
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<Checkbox id="od-delete-from-kb" checked={deleteFromKb} onCheckedChange={(v) => setDeleteFromKb(v === true)} className="shrink-0" /> <Checkbox
id="od-delete-from-kb"
checked={deleteFromKb}
onCheckedChange={(v) => setDeleteFromKb(v === true)}
className="shrink-0"
/>
<label htmlFor="od-delete-from-kb" className="flex-1 cursor-pointer"> <label htmlFor="od-delete-from-kb" className="flex-1 cursor-pointer">
<span className="text-sm text-foreground">Also remove from knowledge base</span> <span className="text-sm text-foreground">Also remove from knowledge base</span>
<p className="text-xs text-muted-foreground mt-0.5">This will permanently delete the file from your knowledge base</p> <p className="text-xs text-muted-foreground mt-0.5">
This will permanently delete the file from your knowledge base
</p>
</label> </label>
</div> </div>
</div> </div>
@ -152,8 +223,20 @@ function ApprovalCard({ interruptData, onDecision }: {
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none"> <div className="px-5 py-4 flex items-center gap-2 select-none">
<Button size="sm" className="rounded-lg gap-1.5" onClick={handleApprove}>Approve <CornerDownLeftIcon className="size-3 opacity-60" /></Button> <Button size="sm" className="rounded-lg gap-1.5" onClick={handleApprove}>
<Button size="sm" variant="ghost" className="rounded-lg text-muted-foreground" onClick={() => { setRejected(); onDecision({ type: "reject", message: "User rejected the action." }); }}>Reject</Button> Approve <CornerDownLeftIcon className="size-3 opacity-60" />
</Button>
<Button
size="sm"
variant="ghost"
className="rounded-lg text-muted-foreground"
onClick={() => {
setRejected();
onDecision({ type: "reject", message: "User rejected the action." });
}}
>
Reject
</Button>
</div> </div>
</> </>
)} )}
@ -164,9 +247,13 @@ function ApprovalCard({ interruptData, onDecision }: {
function ErrorCard({ result }: { result: ErrorResult }) { function ErrorCard({ result }: { result: ErrorResult }) {
return ( return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none"> <div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4"><p className="text-sm font-semibold text-destructive">Failed to delete file</p></div> <div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">Failed to delete file</p>
</div>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4"><p className="text-sm text-muted-foreground">{result.message}</p></div> <div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div> </div>
); );
} }
@ -185,9 +272,13 @@ function NotFoundCard({ result }: { result: NotFoundResult }) {
function AuthErrorCard({ result }: { result: AuthErrorResult }) { function AuthErrorCard({ result }: { result: AuthErrorResult }) {
return ( return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none"> <div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4"><p className="text-sm font-semibold text-destructive">OneDrive authentication expired</p></div> <div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">OneDrive authentication expired</p>
</div>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4"><p className="text-sm text-muted-foreground">{result.message}</p></div> <div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div> </div>
); );
} }
@ -195,23 +286,51 @@ function AuthErrorCard({ result }: { result: AuthErrorResult }) {
function SuccessCard({ result }: { result: SuccessResult }) { function SuccessCard({ result }: { result: SuccessResult }) {
return ( return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none"> <div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4"><p className="text-sm font-semibold text-foreground">{result.message || "File moved to recycle bin"}</p></div> <div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-foreground">
{result.message || "File moved to recycle bin"}
</p>
</div>
{result.deleted_from_kb && ( {result.deleted_from_kb && (
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 text-xs"><span className="text-green-600 dark:text-green-500">Also removed from knowledge base</span></div> <div className="px-5 py-4 text-xs">
<span className="text-green-600 dark:text-green-500">
Also removed from knowledge base
</span>
</div>
</> </>
)} )}
</div> </div>
); );
} }
export const DeleteOneDriveFileToolUI = ({ result }: ToolCallMessagePartProps<{ file_name: string; delete_from_kb?: boolean }, DeleteOneDriveFileResult>) => { export const DeleteOneDriveFileToolUI = ({
result,
}: ToolCallMessagePartProps<
{ file_name: string; delete_from_kb?: boolean },
DeleteOneDriveFileResult
>) => {
if (!result) return null; if (!result) return null;
if (isInterruptResult(result)) { if (isInterruptResult(result)) {
return <ApprovalCard interruptData={result} onDecision={(decision) => { window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })); }} />; return (
<ApprovalCard
interruptData={result}
onDecision={(decision) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
/>
);
} }
if (typeof result === "object" && result !== null && "status" in result && (result as { status: string }).status === "rejected") return null; if (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as { status: string }).status === "rejected"
)
return null;
if (isAuthErrorResult(result)) return <AuthErrorCard result={result} />; if (isAuthErrorResult(result)) return <AuthErrorCard result={result} />;
if (isNotFoundResult(result)) return <NotFoundCard result={result} />; if (isNotFoundResult(result)) return <NotFoundCard result={result} />;
if (isErrorResult(result)) return <ErrorCard result={result} />; if (isErrorResult(result)) return <ErrorCard result={result} />;

View file

@ -1,5 +1,5 @@
export { sanitizeHref } from "./sanitize-href";
export { export {
resolveSafeNavigationHref,
openSafeNavigationHref, openSafeNavigationHref,
resolveSafeNavigationHref,
} from "./safe-navigation"; } from "./safe-navigation";
export { sanitizeHref } from "./sanitize-href";

View file

@ -1,5 +1,5 @@
import { z } from "zod";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { z } from "zod";
/** /**
* Tool UI conventions: * Tool UI conventions:
@ -39,12 +39,7 @@ export const ToolUIRoleSchema = z.enum([
export type ToolUIRole = z.infer<typeof ToolUIRoleSchema>; export type ToolUIRole = z.infer<typeof ToolUIRoleSchema>;
export const ToolUIReceiptOutcomeSchema = z.enum([ export const ToolUIReceiptOutcomeSchema = z.enum(["success", "partial", "failed", "cancelled"]);
"success",
"partial",
"failed",
"cancelled",
]);
export type ToolUIReceiptOutcome = z.infer<typeof ToolUIReceiptOutcomeSchema>; export type ToolUIReceiptOutcome = z.infer<typeof ToolUIReceiptOutcomeSchema>;
@ -81,9 +76,7 @@ export const ActionSchema = z.object({
*/ */
sentence: z.string().optional(), sentence: z.string().optional(),
confirmLabel: z.string().optional(), confirmLabel: z.string().optional(),
variant: z variant: z.enum(["default", "destructive", "secondary", "ghost", "outline"]).optional(),
.enum(["default", "destructive", "secondary", "ghost", "outline"])
.optional(),
icon: z.custom<ReactNode>().optional(), icon: z.custom<ReactNode>().optional(),
loading: z.boolean().optional(), loading: z.boolean().optional(),
disabled: z.boolean().optional(), disabled: z.boolean().optional(),
@ -104,11 +97,10 @@ export const DecisionResultSchema = z.object({
payload: z.record(z.string(), z.unknown()).optional(), payload: z.record(z.string(), z.unknown()).optional(),
}); });
export type DecisionResult< export type DecisionResult<TPayload extends Record<string, unknown> = Record<string, unknown>> =
TPayload extends Record<string, unknown> = Record<string, unknown>, Omit<z.infer<typeof DecisionResultSchema>, "payload"> & {
> = Omit<z.infer<typeof DecisionResultSchema>, "payload"> & {
payload?: TPayload; payload?: TPayload;
}; };
export function createDecisionResult< export function createDecisionResult<
TPayload extends Record<string, unknown> = Record<string, unknown>, TPayload extends Record<string, unknown> = Record<string, unknown>,
@ -152,8 +144,6 @@ export const SerializableActionsConfigSchema = z.object({
confirmTimeout: z.number().positive().optional(), confirmTimeout: z.number().positive().optional(),
}); });
export type SerializableActionsConfig = z.infer< export type SerializableActionsConfig = z.infer<typeof SerializableActionsConfigSchema>;
typeof SerializableActionsConfigSchema
>;
export type SerializableAction = z.infer<typeof SerializableActionSchema>; export type SerializableAction = z.infer<typeof SerializableActionSchema>;

View file

@ -4,8 +4,8 @@ import {
promptCreateRequest, promptCreateRequest,
promptDeleteResponse, promptDeleteResponse,
promptRead, promptRead,
promptUpdateRequest,
promptsListResponse, promptsListResponse,
promptUpdateRequest,
} from "@/contracts/types/prompts.types"; } from "@/contracts/types/prompts.types";
import { ValidationError } from "@/lib/error"; import { ValidationError } from "@/lib/error";
import { baseApiService } from "./base-api.service"; import { baseApiService } from "./base-api.service";

View file

@ -27,10 +27,7 @@ export interface ContentPartsState {
toolCallIndices: Map<string, number>; toolCallIndices: Map<string, number>;
} }
function areThinkingStepsEqual( function areThinkingStepsEqual(current: ThinkingStepData[], next: ThinkingStepData[]): boolean {
current: ThinkingStepData[],
next: ThinkingStepData[]
): boolean {
if (current.length !== next.length) return false; if (current.length !== next.length) return false;
for (let i = 0; i < current.length; i += 1) { for (let i = 0; i < current.length; i += 1) {