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, {
"name": c.name, "id": c.id,
"user_email": cfg.get("user_email"), "name": c.name,
"auth_expired": cfg.get("auth_expired", False), "user_email": cfg.get("user_email"),
}) "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,18 +208,20 @@ 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: ):
return f"Download failed: {resp.status_code}" if resp.status_code != 200:
with open(dest_path, "wb") as f: return f"Download failed: {resp.status_code}"
async for chunk in resp.aiter_bytes(chunk_size=5 * 1024 * 1024): with open(dest_path, "wb") as f:
f.write(chunk) async for chunk in resp.aiter_bytes(chunk_size=5 * 1024 * 1024):
f.write(chunk)
return None return None
async def create_file( async def create_file(

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

@ -666,62 +666,62 @@ export default function NewChatPage() {
const scheduleFlush = () => batcher.schedule(flushMessages); const scheduleFlush = () => batcher.schedule(flushMessages);
for await (const parsed of readSSEStream(response)) { for await (const parsed of readSSEStream(response)) {
switch (parsed.type) { switch (parsed.type) {
case "text-delta": case "text-delta":
appendText(contentPartsState, parsed.delta); appendText(contentPartsState, parsed.delta);
scheduleFlush(); scheduleFlush();
break; break;
case "tool-input-start": case "tool-input-start":
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {}); addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
batcher.flush(); batcher.flush();
break; break;
case "tool-input-available": { case "tool-input-available": {
if (toolCallIndices.has(parsed.toolCallId)) { if (toolCallIndices.has(parsed.toolCallId)) {
updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} }); updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} });
} else { } else {
addToolCall( addToolCall(
contentPartsState, contentPartsState,
TOOLS_WITH_UI, TOOLS_WITH_UI,
parsed.toolCallId, parsed.toolCallId,
parsed.toolName, parsed.toolName,
parsed.input || {} parsed.input || {}
); );
}
batcher.flush();
break;
} }
batcher.flush();
break;
}
case "tool-output-available": { case "tool-output-available": {
updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output }); updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output });
markInterruptsCompleted(contentParts); markInterruptsCompleted(contentParts);
if (parsed.output?.status === "pending" && parsed.output?.podcast_id) { if (parsed.output?.status === "pending" && parsed.output?.podcast_id) {
const idx = toolCallIndices.get(parsed.toolCallId); const idx = toolCallIndices.get(parsed.toolCallId);
if (idx !== undefined) { if (idx !== undefined) {
const part = contentParts[idx]; const part = contentParts[idx];
if (part?.type === "tool-call" && part.toolName === "generate_podcast") { if (part?.type === "tool-call" && part.toolName === "generate_podcast") {
setActivePodcastTaskId(String(parsed.output.podcast_id)); setActivePodcastTaskId(String(parsed.output.podcast_id));
}
} }
} }
batcher.flush();
break;
} }
batcher.flush();
break;
}
case "data-thinking-step": { case "data-thinking-step": {
const stepData = parsed.data as ThinkingStepData; const stepData = parsed.data as ThinkingStepData;
if (stepData?.id) { if (stepData?.id) {
currentThinkingSteps.set(stepData.id, stepData); currentThinkingSteps.set(stepData.id, stepData);
const didUpdate = updateThinkingSteps(contentPartsState, currentThinkingSteps); const didUpdate = updateThinkingSteps(contentPartsState, currentThinkingSteps);
if (didUpdate) { if (didUpdate) {
scheduleFlush(); scheduleFlush();
}
} }
break;
} }
break;
}
case "data-thread-title-update": { case "data-thread-title-update": {
const titleData = parsed.data as { threadId: number; title: string }; const titleData = parsed.data as { threadId: number; title: string };
if (titleData?.title && titleData?.threadId === currentThreadId) { if (titleData?.title && titleData?.threadId === currentThreadId) {
setCurrentThread((prev) => (prev ? { ...prev, title: titleData.title } : prev)); setCurrentThread((prev) => (prev ? { ...prev, title: titleData.title } : prev));
@ -1012,7 +1012,7 @@ export default function NewChatPage() {
throw new Error(`Backend error: ${response.status}`); throw new Error(`Backend error: ${response.status}`);
} }
const flushMessages = () => { const flushMessages = () => {
setMessages((prev) => setMessages((prev) =>
prev.map((m) => prev.map((m) =>
m.id === assistantMsgId m.id === assistantMsgId
@ -1024,55 +1024,55 @@ export default function NewChatPage() {
const scheduleFlush = () => batcher.schedule(flushMessages); const scheduleFlush = () => batcher.schedule(flushMessages);
for await (const parsed of readSSEStream(response)) { for await (const parsed of readSSEStream(response)) {
switch (parsed.type) { switch (parsed.type) {
case "text-delta": case "text-delta":
appendText(contentPartsState, parsed.delta); appendText(contentPartsState, parsed.delta);
scheduleFlush(); scheduleFlush();
break; break;
case "tool-input-start": case "tool-input-start":
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {}); addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
batcher.flush(); batcher.flush();
break; break;
case "tool-input-available": case "tool-input-available":
if (toolCallIndices.has(parsed.toolCallId)) { if (toolCallIndices.has(parsed.toolCallId)) {
updateToolCall(contentPartsState, parsed.toolCallId, { updateToolCall(contentPartsState, parsed.toolCallId, {
args: parsed.input || {}, args: parsed.input || {},
}); });
} else { } else {
addToolCall( addToolCall(
contentPartsState, contentPartsState,
TOOLS_WITH_UI, TOOLS_WITH_UI,
parsed.toolCallId, parsed.toolCallId,
parsed.toolName, parsed.toolName,
parsed.input || {} parsed.input || {}
); );
}
batcher.flush();
break;
case "tool-output-available":
updateToolCall(contentPartsState, parsed.toolCallId, {
result: parsed.output,
});
markInterruptsCompleted(contentParts);
batcher.flush();
break;
case "data-thinking-step": {
const stepData = parsed.data as ThinkingStepData;
if (stepData?.id) {
currentThinkingSteps.set(stepData.id, stepData);
const didUpdate = updateThinkingSteps(contentPartsState, currentThinkingSteps);
if (didUpdate) {
scheduleFlush();
} }
} batcher.flush();
break; break;
}
case "data-interrupt-request": { case "tool-output-available":
updateToolCall(contentPartsState, parsed.toolCallId, {
result: parsed.output,
});
markInterruptsCompleted(contentParts);
batcher.flush();
break;
case "data-thinking-step": {
const stepData = parsed.data as ThinkingStepData;
if (stepData?.id) {
currentThinkingSteps.set(stepData.id, stepData);
const didUpdate = updateThinkingSteps(contentPartsState, currentThinkingSteps);
if (didUpdate) {
scheduleFlush();
}
}
break;
}
case "data-interrupt-request": {
const interruptData = parsed.data as Record<string, unknown>; const interruptData = parsed.data as Record<string, unknown>;
const actionRequests = (interruptData.action_requests ?? []) as Array<{ const actionRequests = (interruptData.action_requests ?? []) as Array<{
name: string; name: string;
@ -1330,7 +1330,7 @@ export default function NewChatPage() {
throw new Error(`Backend error: ${response.status}`); throw new Error(`Backend error: ${response.status}`);
} }
const flushMessages = () => { const flushMessages = () => {
setMessages((prev) => setMessages((prev) =>
prev.map((m) => prev.map((m) =>
m.id === assistantMsgId m.id === assistantMsgId
@ -1342,63 +1342,63 @@ export default function NewChatPage() {
const scheduleFlush = () => batcher.schedule(flushMessages); const scheduleFlush = () => batcher.schedule(flushMessages);
for await (const parsed of readSSEStream(response)) { for await (const parsed of readSSEStream(response)) {
switch (parsed.type) { switch (parsed.type) {
case "text-delta": case "text-delta":
appendText(contentPartsState, parsed.delta); appendText(contentPartsState, parsed.delta);
scheduleFlush(); scheduleFlush();
break; break;
case "tool-input-start": case "tool-input-start":
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {}); addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
batcher.flush(); batcher.flush();
break; break;
case "tool-input-available": case "tool-input-available":
if (toolCallIndices.has(parsed.toolCallId)) { if (toolCallIndices.has(parsed.toolCallId)) {
updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} }); updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} });
} else { } else {
addToolCall( addToolCall(
contentPartsState, contentPartsState,
TOOLS_WITH_UI, TOOLS_WITH_UI,
parsed.toolCallId, parsed.toolCallId,
parsed.toolName, parsed.toolName,
parsed.input || {} parsed.input || {}
); );
} }
batcher.flush(); batcher.flush();
break; break;
case "tool-output-available": case "tool-output-available":
updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output }); updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output });
markInterruptsCompleted(contentParts); markInterruptsCompleted(contentParts);
if (parsed.output?.status === "pending" && parsed.output?.podcast_id) { if (parsed.output?.status === "pending" && parsed.output?.podcast_id) {
const idx = toolCallIndices.get(parsed.toolCallId); const idx = toolCallIndices.get(parsed.toolCallId);
if (idx !== undefined) { if (idx !== undefined) {
const part = contentParts[idx]; const part = contentParts[idx];
if (part?.type === "tool-call" && part.toolName === "generate_podcast") { if (part?.type === "tool-call" && part.toolName === "generate_podcast") {
setActivePodcastTaskId(String(parsed.output.podcast_id)); setActivePodcastTaskId(String(parsed.output.podcast_id));
}
} }
} }
} batcher.flush();
batcher.flush(); break;
break;
case "data-thinking-step": { case "data-thinking-step": {
const stepData = parsed.data as ThinkingStepData; const stepData = parsed.data as ThinkingStepData;
if (stepData?.id) { if (stepData?.id) {
currentThinkingSteps.set(stepData.id, stepData); currentThinkingSteps.set(stepData.id, stepData);
const didUpdate = updateThinkingSteps(contentPartsState, currentThinkingSteps); const didUpdate = updateThinkingSteps(contentPartsState, currentThinkingSteps);
if (didUpdate) { if (didUpdate) {
scheduleFlush(); scheduleFlush();
}
} }
break;
} }
break;
}
case "error": case "error":
throw new Error(parsed.errorText || "Server error"); throw new Error(parsed.errorText || "Server error");
}
} }
}
batcher.flush(); batcher.flush();

View file

@ -166,13 +166,13 @@ export default function OnboardPage() {
{/* Form card */} {/* Form card */}
<div className="rounded-xl border bg-background dark:bg-neutral-900 flex-1 min-h-0 overflow-y-auto px-6 py-6"> <div className="rounded-xl border bg-background dark:bg-neutral-900 flex-1 min-h-0 overflow-y-auto px-6 py-6">
<LLMConfigForm <LLMConfigForm
searchSpaceId={searchSpaceId} searchSpaceId={searchSpaceId}
onSubmit={handleSubmit} onSubmit={handleSubmit}
mode="create" mode="create"
showAdvanced={true} showAdvanced={true}
formId="onboard-config-form" formId="onboard-config-form"
initialData={{ initialData={{
citations_enabled: true, citations_enabled: true,
use_default_system_instructions: true, use_default_system_instructions: true,
}} }}
@ -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";
@ -117,10 +121,10 @@ const AssistantMessageInner: FC = () => {
create_confluence_page: CreateConfluencePageToolUI, create_confluence_page: CreateConfluencePageToolUI,
update_confluence_page: UpdateConfluencePageToolUI, update_confluence_page: UpdateConfluencePageToolUI,
delete_confluence_page: DeleteConfluencePageToolUI, delete_confluence_page: DeleteConfluencePageToolUI,
web_search: () => null, web_search: () => null,
link_preview: () => null, link_preview: () => null,
multi_link_preview: () => null, multi_link_preview: () => null,
scrape_webpage: () => null, scrape_webpage: () => null,
}, },
Fallback: ToolFallback, Fallback: ToolFallback,
}, },

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
@ -299,11 +298,11 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
onBack={handleBackFromEdit} onBack={handleBackFromEdit}
onQuickIndex={(() => { onQuickIndex={(() => {
const cfg = connectorConfig || editingConnector.config; const cfg = connectorConfig || editingConnector.config;
const isDriveOrOneDrive = const isDriveOrOneDrive =
editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" || editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" || editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
editingConnector.connector_type === "ONEDRIVE_CONNECTOR"; editingConnector.connector_type === "ONEDRIVE_CONNECTOR";
const hasDriveItems = isDriveOrOneDrive const hasDriveItems = isDriveOrOneDrive
? ((cfg?.selected_folders as unknown[]) ?? []).length > 0 || ? ((cfg?.selected_folders as unknown[]) ?? []).length > 0 ||
((cfg?.selected_files as unknown[]) ?? []).length > 0 ((cfg?.selected_files as unknown[]) ?? []).length > 0
: true; : true;

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

@ -779,11 +779,11 @@ export const useConnectorDialog = () => {
}); });
} }
// Handle Google Drive / OneDrive folder selection (regular and Composio) // Handle Google Drive / OneDrive folder selection (regular and Composio)
if ( if (
(indexingConfig.connectorType === "GOOGLE_DRIVE_CONNECTOR" || (indexingConfig.connectorType === "GOOGLE_DRIVE_CONNECTOR" ||
indexingConfig.connectorType === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" || indexingConfig.connectorType === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
indexingConfig.connectorType === "ONEDRIVE_CONNECTOR") && indexingConfig.connectorType === "ONEDRIVE_CONNECTOR") &&
indexingConnectorConfig indexingConnectorConfig
) { ) {
const selectedFolders = indexingConnectorConfig.selected_folders as const selectedFolders = indexingConnectorConfig.selected_folders as

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 : {
? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 8}px` bottom: editorContainerRef.current
: "200px" } ? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 8}px`
), : "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

@ -105,8 +105,8 @@ export function Sidebar({
> >
{/* Header - search space name or collapse button when collapsed */} {/* Header - search space name or collapse button when collapsed */}
{isCollapsed ? ( {isCollapsed ? (
<div className="flex h-12 shrink-0 items-center justify-center border-b"> <div className="flex h-12 shrink-0 items-center justify-center border-b">
<SidebarCollapseButton <SidebarCollapseButton
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
onToggle={onToggleCollapse ?? (() => {})} onToggle={onToggleCollapse ?? (() => {})}
disableTooltip={disableTooltips} disableTooltip={disableTooltips}

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,113 +53,192 @@ 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 },
const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom); ref
const [highlightedIndex, setHighlightedIndex] = useState(0); ) {
const [customPrompts, setCustomPrompts] = useState<PromptRead[]>([]); const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom);
const scrollContainerRef = useRef<HTMLDivElement>(null); const [highlightedIndex, setHighlightedIndex] = useState(0);
const shouldScrollRef = useRef(false); const [customPrompts, setCustomPrompts] = useState<PromptRead[]>([]);
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map()); const scrollContainerRef = useRef<HTMLDivElement>(null);
const shouldScrollRef = useRef(false);
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(() => {
const customs = customPrompts.map((a) => ({ const customs = customPrompts.map((a) => ({
name: a.name, name: a.name,
prompt: a.prompt, prompt: a.prompt,
mode: a.mode as "transform" | "explore", mode: a.mode as "transform" | "explore",
icon: a.icon || "zap", icon: a.icon || "zap",
})); }));
return [...DEFAULT_ACTIONS, ...customs]; return [...DEFAULT_ACTIONS, ...customs];
}, [customPrompts]); }, [customPrompts]);
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
const prevSearchRef = useRef(externalSearch); const prevSearchRef = useRef(externalSearch);
if (prevSearchRef.current !== externalSearch) { if (prevSearchRef.current !== externalSearch) {
prevSearchRef.current = externalSearch; prevSearchRef.current = externalSearch;
if (highlightedIndex !== 0) { if (highlightedIndex !== 0) {
setHighlightedIndex(0); setHighlightedIndex(0);
}
} }
}
const handleSelect = useCallback( const handleSelect = useCallback(
(index: number) => { (index: number) => {
const action = filtered[index]; const action = filtered[index];
if (!action) return; if (!action) return;
onSelect({ name: action.name, prompt: action.prompt, mode: action.mode }); onSelect({ name: action.name, prompt: action.prompt, mode: action.mode });
}, },
[filtered, onSelect] [filtered, onSelect]
); );
// Auto-scroll highlighted item into view // Auto-scroll highlighted item into view
useEffect(() => { useEffect(() => {
if (!shouldScrollRef.current) return; if (!shouldScrollRef.current) return;
shouldScrollRef.current = false; shouldScrollRef.current = false;
const rafId = requestAnimationFrame(() => { const rafId = requestAnimationFrame(() => {
const item = itemRefs.current.get(highlightedIndex); const item = itemRefs.current.get(highlightedIndex);
const container = scrollContainerRef.current; const container = scrollContainerRef.current;
if (item && container) { if (item && container) {
const itemRect = item.getBoundingClientRect(); const itemRect = item.getBoundingClientRect();
const containerRect = container.getBoundingClientRect(); const containerRect = container.getBoundingClientRect();
if (itemRect.top < containerRect.top || itemRect.bottom > containerRect.bottom) { if (itemRect.top < containerRect.top || itemRect.bottom > containerRect.bottom) {
item.scrollIntoView({ block: "nearest" }); item.scrollIntoView({ block: "nearest" });
}
} }
}); }
});
return () => cancelAnimationFrame(rafId); return () => cancelAnimationFrame(rafId);
}, [highlightedIndex]); }, [highlightedIndex]);
useImperativeHandle( useImperativeHandle(
ref, ref,
() => ({ () => ({
selectHighlighted: () => handleSelect(highlightedIndex), selectHighlighted: () => handleSelect(highlightedIndex),
moveUp: () => { moveUp: () => {
shouldScrollRef.current = true; shouldScrollRef.current = true;
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1)); setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1));
}, },
moveDown: () => { moveDown: () => {
shouldScrollRef.current = true; shouldScrollRef.current = true;
setHighlightedIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0)); setHighlightedIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0));
}, },
}), }),
[filtered.length, highlightedIndex, handleSelect] [filtered.length, highlightedIndex, handleSelect]
); );
if (filtered.length === 0) return null; if (filtered.length === 0) return null;
const defaultFiltered = filtered.filter((_, i) => i < DEFAULT_ACTIONS.length); const defaultFiltered = filtered.filter((_, i) => i < DEFAULT_ACTIONS.length);
const customFiltered = filtered.filter((_, i) => i >= DEFAULT_ACTIONS.length); const customFiltered = filtered.filter((_, i) => i >= DEFAULT_ACTIONS.length);
return ( return (
<div <div
className="w-64 rounded-lg border bg-popover shadow-lg overflow-hidden" className="w-64 rounded-lg border bg-popover shadow-lg overflow-hidden"
style={containerStyle} style={containerStyle}
> >
<div ref={scrollContainerRef} className="max-h-48 overflow-y-auto py-1"> <div ref={scrollContainerRef} className="max-h-48 overflow-y-auto py-1">
{defaultFiltered.map((action, index) => ( {defaultFiltered.map((action, index) => (
<button
key={action.name}
ref={(el) => {
if (el) itemRefs.current.set(index, el);
else itemRefs.current.delete(index);
}}
type="button"
onClick={() => handleSelect(index)}
onMouseEnter={() => setHighlightedIndex(index)}
className={cn(
"flex w-full items-center gap-2 px-3 py-1.5 text-sm cursor-pointer",
index === highlightedIndex ? "bg-accent" : "hover:bg-accent/50"
)}
>
<span className="text-muted-foreground">
{ICONS[action.icon] ?? <Zap className="size-3.5" />}
</span>
<span className="truncate">{action.name}</span>
</button>
))}
{customFiltered.length > 0 && <div className="my-1 h-px bg-border mx-2" />}
{customFiltered.map((action, i) => {
const index = defaultFiltered.length + i;
return (
<button <button
key={action.name} key={action.name}
ref={(el) => { ref={(el) => {
@ -174,52 +253,27 @@ 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">
<Zap className="size-3.5" />
</span>
<span className="truncate">{action.name}</span> <span className="truncate">{action.name}</span>
</button> </button>
))} );
})}
{customFiltered.length > 0 && ( <div className="my-1 h-px bg-border mx-2" />
<div className="my-1 h-px bg-border mx-2" /> <button
)} type="button"
onClick={() => {
{customFiltered.map((action, i) => { onDone();
const index = defaultFiltered.length + i; setUserSettingsDialog({ open: true, initialTab: "prompts" });
return ( }}
<button className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-accent/50 cursor-pointer"
key={action.name} >
ref={(el) => { <Plus className="size-3.5" />
if (el) itemRefs.current.set(index, el); <span>Create prompt</span>
else itemRefs.current.delete(index); </button>
}}
type="button"
onClick={() => handleSelect(index)}
onMouseEnter={() => setHighlightedIndex(index)}
className={cn(
"flex w-full items-center gap-2 px-3 py-1.5 text-sm cursor-pointer",
index === highlightedIndex ? "bg-accent" : "hover:bg-accent/50"
)}
>
<span className="text-muted-foreground"><Zap className="size-3.5" /></span>
<span className="truncate">{action.name}</span>
</button>
);
})}
<div className="my-1 h-px bg-border mx-2" />
<button
type="button"
onClick={() => {
onDone();
setUserSettingsDialog({ open: true, initialTab: "prompts" });
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-accent/50 cursor-pointer"
>
<Plus className="size-3.5" />
<span>Create prompt</span>
</button>
</div>
</div> </div>
); </div>
} );
); });

View file

@ -155,10 +155,10 @@ const PublicAssistantMessage: FC = () => {
generate_video_presentation: GenerateVideoPresentationToolUI, generate_video_presentation: GenerateVideoPresentationToolUI,
display_image: GenerateImageToolUI, display_image: GenerateImageToolUI,
generate_image: GenerateImageToolUI, generate_image: GenerateImageToolUI,
web_search: () => null, web_search: () => null,
link_preview: () => null, link_preview: () => null,
multi_link_preview: () => null, multi_link_preview: () => null,
scrape_webpage: () => null, scrape_webpage: () => null,
}, },
Fallback: ToolFallback, Fallback: ToolFallback,
}, },

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>
)} )}
@ -399,10 +398,8 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
onOpenChange={(open) => !open && setConfigToDelete(null)} onOpenChange={(open) => !open && setConfigToDelete(null)}
> >
<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>?
@ -410,14 +407,14 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel> <AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={handleDelete} onClick={handleDelete}
disabled={isDeleting} disabled={isDeleting}
className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90" className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
> >
<span className={isDeleting ? "opacity-0" : ""}>Delete</span> <span className={isDeleting ? "opacity-0" : ""}>Delete</span>
{isDeleting && <Spinner size="sm" className="absolute" />} {isDeleting && <Spinner size="sm" className="absolute" />}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>

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
@ -449,14 +450,14 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
disabled={isDeleting} disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
> >
{isDeleting ? ( {isDeleting ? (
<> <>
<Spinner size="sm" className="mr-2" /> <Spinner size="sm" className="mr-2" />
Deleting Deleting
</> </>
) : ( ) : (
"Delete" "Delete"
)} )}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>

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>
@ -234,7 +232,7 @@ export function ImageConfigDialog({
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`, WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
}} }}
> >
{isGlobal && config && ( {isGlobal && config && (
<> <>
<Alert className="mb-5 border-amber-500/30 bg-amber-500/5"> <Alert className="mb-5 border-amber-500/30 bg-amber-500/5">
<AlertCircle className="size-4 text-amber-500" /> <AlertCircle className="size-4 text-amber-500" />
@ -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>
)} )}
@ -442,25 +427,25 @@ export function ImageConfigDialog({
Cancel Cancel
</Button> </Button>
{mode === "create" || (mode === "edit" && !isGlobal) ? ( {mode === "create" || (mode === "edit" && !isGlobal) ? (
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
disabled={isSubmitting || !isFormValid} disabled={isSubmitting || !isFormValid}
className="relative text-sm h-9 min-w-[120px]" className="relative text-sm h-9 min-w-[120px]"
> >
<span className={isSubmitting ? "opacity-0" : ""}> <span className={isSubmitting ? "opacity-0" : ""}>
{mode === "edit" ? "Save Changes" : "Create & Use"} {mode === "edit" ? "Save Changes" : "Create & Use"}
</span> </span>
{isSubmitting && <Spinner size="sm" className="absolute" />} {isSubmitting && <Spinner size="sm" className="absolute" />}
</Button> </Button>
) : isGlobal && config ? ( ) : isGlobal && config ? (
<Button <Button
className="relative text-sm h-9" className="relative text-sm h-9"
onClick={handleUseGlobalConfig} onClick={handleUseGlobalConfig}
disabled={isSubmitting} disabled={isSubmitting}
> >
<span className={isSubmitting ? "opacity-0" : ""}>Use This Model</span> <span className={isSubmitting ? "opacity-0" : ""}>Use This Model</span>
{isSubmitting && <Spinner size="sm" className="absolute" />} {isSubmitting && <Spinner size="sm" className="absolute" />}
</Button> </Button>
) : null} ) : null}
</div> </div>
</DialogContent> </DialogContent>

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,
@ -219,26 +219,22 @@ export function LLMConfigForm({
)} )}
/> />
{/* Custom Provider (conditional) */} {/* Custom Provider (conditional) */}
{watchProvider === "CUSTOM" && ( {watchProvider === "CUSTOM" && (
<FormField <FormField
control={form.control} control={form.control}
name="custom_provider" name="custom_provider"
render={({ field }) => ( render={({ field }) => (
<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" </FormControl>
{...field} <FormMessage />
value={field.value ?? ""} </FormItem>
/> )}
</FormControl> />
<FormMessage /> )}
</FormItem>
)}
/>
)}
{/* Model Name with Combobox */} {/* Model Name with Combobox */}
<FormField <FormField
@ -383,29 +379,29 @@ export function LLMConfigForm({
/> />
</div> </div>
{/* Ollama Quick Actions */} {/* Ollama Quick Actions */}
{watchProvider === "OLLAMA" && ( {watchProvider === "OLLAMA" && (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
className="h-7 text-xs" className="h-7 text-xs"
onClick={() => form.setValue("api_base", "http://localhost:11434")} onClick={() => form.setValue("api_base", "http://localhost:11434")}
> >
localhost:11434 localhost:11434
</Button> </Button>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
className="h-7 text-xs" className="h-7 text-xs"
onClick={() => form.setValue("api_base", "http://host.docker.internal:11434")} onClick={() => form.setValue("api_base", "http://host.docker.internal:11434")}
> >
Docker Docker
</Button> </Button>
</div> </div>
)} )}
</div> </div>
{/* Advanced Parameters */} {/* Advanced Parameters */}

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>
@ -184,7 +182,7 @@ export function ModelConfigDialog({
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`, WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
}} }}
> >
{isGlobal && mode !== "create" && ( {isGlobal && mode !== "create" && (
<Alert className="mb-5 border-amber-500/30 bg-amber-500/5"> <Alert className="mb-5 border-amber-500/30 bg-amber-500/5">
<AlertCircle className="size-4 text-amber-500" /> <AlertCircle className="size-4 text-amber-500" />
<AlertDescription className="text-sm text-amber-700 dark:text-amber-400"> <AlertDescription className="text-sm text-amber-700 dark:text-amber-400">
@ -195,13 +193,13 @@ export function ModelConfigDialog({
)} )}
{mode === "create" ? ( {mode === "create" ? (
<LLMConfigForm <LLMConfigForm
searchSpaceId={searchSpaceId} searchSpaceId={searchSpaceId}
onSubmit={handleSubmit} onSubmit={handleSubmit}
mode="create" mode="create"
formId="model-config-form" formId="model-config-form"
/> />
) : isGlobal && config ? ( ) : isGlobal && config ? (
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-4"> <div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
@ -288,9 +286,9 @@ export function ModelConfigDialog({
citations_enabled: config.citations_enabled, citations_enabled: config.citations_enabled,
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
}} }}
onSubmit={handleSubmit} onSubmit={handleSubmit}
mode="edit" mode="edit"
formId="model-config-form" formId="model-config-form"
/> />
) : null} ) : null}
</div> </div>
@ -307,26 +305,26 @@ export function ModelConfigDialog({
Cancel Cancel
</Button> </Button>
{mode === "create" || (!isGlobal && config) ? ( {mode === "create" || (!isGlobal && config) ? (
<Button <Button
type="submit" type="submit"
form="model-config-form" form="model-config-form"
disabled={isSubmitting} disabled={isSubmitting}
className="relative text-sm h-9 min-w-[120px]" className="relative text-sm h-9 min-w-[120px]"
> >
<span className={isSubmitting ? "opacity-0" : ""}> <span className={isSubmitting ? "opacity-0" : ""}>
{mode === "edit" ? "Save Changes" : "Create & Use"} {mode === "edit" ? "Save Changes" : "Create & Use"}
</span> </span>
{isSubmitting && <Spinner size="sm" className="absolute" />} {isSubmitting && <Spinner size="sm" className="absolute" />}
</Button> </Button>
) : isGlobal && config ? ( ) : isGlobal && config ? (
<Button <Button
className="relative text-sm h-9" className="relative text-sm h-9"
onClick={handleUseGlobalConfig} onClick={handleUseGlobalConfig}
disabled={isSubmitting} disabled={isSubmitting}
> >
<span className={isSubmitting ? "opacity-0" : ""}>Use This Model</span> <span className={isSubmitting ? "opacity-0" : ""}>Use This Model</span>
{isSubmitting && <Spinner size="sm" className="absolute" />} {isSubmitting && <Spinner size="sm" className="absolute" />}
</Button> </Button>
) : null} ) : null}
</div> </div>
</DialogContent> </DialogContent>

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,463 +1,395 @@
"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,
document: FileText, document: FileText,
article: Newspaper, article: Newspaper,
api: Database, api: Database,
code: Code2, code: Code2,
other: File, other: File,
}; };
function useHoverPopover(delay = 100) { function useHoverPopover(delay = 100) {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null); const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const containerRef = React.useRef<HTMLDivElement>(null); const containerRef = React.useRef<HTMLDivElement>(null);
const handleMouseEnter = React.useCallback(() => { const handleMouseEnter = React.useCallback(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current); if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setOpen(true), delay); timeoutRef.current = setTimeout(() => setOpen(true), delay);
}, [delay]); }, [delay]);
const handleMouseLeave = React.useCallback(() => { const handleMouseLeave = React.useCallback(() => {
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]);
const handleFocus = React.useCallback(() => { const handleFocus = React.useCallback(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current); if (timeoutRef.current) clearTimeout(timeoutRef.current);
setOpen(true); setOpen(true);
}, []); }, []);
const handleBlur = React.useCallback( const handleBlur = React.useCallback(
(e: React.FocusEvent) => { (e: React.FocusEvent) => {
const relatedTarget = e.relatedTarget as HTMLElement | null; const relatedTarget = e.relatedTarget as HTMLElement | null;
if (containerRef.current?.contains(relatedTarget)) { if (containerRef.current?.contains(relatedTarget)) {
return; return;
} }
if (relatedTarget?.closest("[data-radix-popper-content-wrapper]")) { if (relatedTarget?.closest("[data-radix-popper-content-wrapper]")) {
return; return;
} }
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(() => {
return () => { return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current); if (timeoutRef.current) clearTimeout(timeoutRef.current);
}; };
}, []); }, []);
return { return {
open, open,
setOpen, setOpen,
containerRef, containerRef,
handleMouseEnter, handleMouseEnter,
handleMouseLeave, handleMouseLeave,
handleFocus, handleFocus,
handleBlur, handleBlur,
}; };
} }
export interface CitationListProps { export interface CitationListProps {
id: string; id: string;
citations: SerializableCitation[]; citations: SerializableCitation[];
variant?: CitationVariant; variant?: CitationVariant;
maxVisible?: number; maxVisible?: number;
className?: string; className?: string;
onNavigate?: (href: string, citation: SerializableCitation) => void; onNavigate?: (href: string, citation: SerializableCitation) => void;
} }
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 const overflowCitations = shouldTruncate ? citations.slice(maxVisible) : [];
? citations.slice(0, maxVisible) const overflowCount = overflowCitations.length;
: citations;
const overflowCitations = shouldTruncate ? citations.slice(maxVisible) : [];
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") {
return ( return (
<StackedCitations <StackedCitations
id={id} id={id}
citations={citations} citations={citations}
className={className} className={className}
onNavigate={onNavigate} onNavigate={onNavigate}
/> />
); );
} }
if (variant === "default") { if (variant === "default") {
return ( return (
<div <div
className={cn("isolate flex flex-col gap-4", className)} className={cn("isolate flex flex-col gap-4", className)}
data-tool-ui-id={id} data-tool-ui-id={id}
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} {shouldTruncate && (
variant="default" <OverflowIndicator
onNavigate={onNavigate} citations={overflowCitations}
/> count={overflowCount}
))} variant="default"
{shouldTruncate && ( onNavigate={onNavigate}
<OverflowIndicator />
citations={overflowCitations} )}
count={overflowCount} </div>
variant="default" );
onNavigate={onNavigate} }
/>
)}
</div>
);
}
return ( return (
<div <div
className={cn("isolate", wrapperClass, className)} className={cn("isolate", wrapperClass, className)}
data-tool-ui-id={id} data-tool-ui-id={id}
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} {shouldTruncate && (
variant={variant} <OverflowIndicator
onNavigate={onNavigate} citations={overflowCitations}
/> count={overflowCount}
))} variant={variant}
{shouldTruncate && ( onNavigate={onNavigate}
<OverflowIndicator />
citations={overflowCitations} )}
count={overflowCount} </div>
variant={variant} );
onNavigate={onNavigate}
/>
)}
</div>
);
} }
interface OverflowIndicatorProps { interface OverflowIndicatorProps {
citations: SerializableCitation[]; citations: SerializableCitation[];
count: number; count: number;
variant: CitationVariant; variant: CitationVariant;
onNavigate?: (href: string, citation: SerializableCitation) => void; onNavigate?: (href: string, citation: SerializableCitation) => void;
} }
function OverflowIndicator({ function OverflowIndicator({ citations, count, variant, onNavigate }: OverflowIndicatorProps) {
citations, const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover();
count,
variant,
onNavigate,
}: OverflowIndicatorProps) {
const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover();
const handleClick = (citation: SerializableCitation) => { const handleClick = (citation: SerializableCitation) => {
const href = resolveSafeNavigationHref(citation.href); const href = resolveSafeNavigationHref(citation.href);
if (!href) return; if (!href) return;
if (onNavigate) { if (onNavigate) {
onNavigate(href, citation); onNavigate(href, citation);
} else { } else {
openSafeNavigationHref(href); openSafeNavigationHref(href);
} }
}; };
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} </div>
onClick={() => handleClick(citation)} );
/>
))}
</div>
);
if (variant === "inline") { if (variant === "inline") {
return ( return (
<Popover open={open}> <Popover open={open}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button <button
type="button" type="button"
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
className={cn( className={cn(
"inline-flex items-center gap-1 rounded-md px-2 py-1", "inline-flex items-center gap-1 rounded-md px-2 py-1",
"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>
</button> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
side="top" side="top"
align="start" align="start"
className="w-80 p-1" className="w-80 p-1"
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
onOpenAutoFocus={(e) => e.preventDefault()} onOpenAutoFocus={(e) => e.preventDefault()}
> >
{popoverContent} {popoverContent}
</PopoverContent> </PopoverContent>
</Popover> </Popover>
); );
} }
// Default variant // Default variant
return ( return (
<Popover open={open}> <Popover open={open}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button <button
type="button" type="button"
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
className={cn( className={cn(
"flex items-center justify-center rounded-xl px-4 py-3", "flex items-center justify-center rounded-xl px-4 py-3",
"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 </button>
</span> </PopoverTrigger>
</button> <PopoverContent
</PopoverTrigger> side="bottom"
<PopoverContent align="start"
side="bottom" className="w-80 p-1"
align="start" onMouseEnter={handleMouseEnter}
className="w-80 p-1" onMouseLeave={handleMouseLeave}
onMouseEnter={handleMouseEnter} onOpenAutoFocus={(e) => e.preventDefault()}
onMouseLeave={handleMouseLeave} >
onOpenAutoFocus={(e) => e.preventDefault()} {popoverContent}
> </PopoverContent>
{popoverContent} </Popover>
</PopoverContent> );
</Popover>
);
} }
interface OverflowItemProps { interface OverflowItemProps {
citation: SerializableCitation; citation: SerializableCitation;
onClick: () => void; onClick: () => void;
} }
function OverflowItem({ citation, onClick }: OverflowItemProps) { function OverflowItem({ citation, onClick }: OverflowItemProps) {
const TypeIcon = TYPE_ICONS[citation.type ?? "webpage"] ?? Globe; const TypeIcon = TYPE_ICONS[citation.type ?? "webpage"] ?? Globe;
return ( return (
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
className="group hover:bg-muted focus-visible:bg-muted flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2 py-2 text-left transition-colors focus-visible:outline-none" className="group hover:bg-muted focus-visible:bg-muted flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2 py-2 text-left transition-colors focus-visible:outline-none"
> >
{citation.favicon ? ( {citation.favicon ? (
// biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config // biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config
<img <img
src={citation.favicon} src={citation.favicon}
alt="" alt=""
aria-hidden="true" aria-hidden="true"
width={16} width={16}
height={16} height={16}
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">
/> <p className="group-hover:decoration-foreground/30 truncate text-sm font-medium group-hover:underline group-hover:underline-offset-2">
)} {citation.title}
<div className="min-w-0 flex-1"> </p>
<p className="group-hover:decoration-foreground/30 truncate text-sm font-medium group-hover:underline group-hover:underline-offset-2"> <p className="text-muted-foreground truncate text-xs">{citation.domain}</p>
{citation.title} </div>
</p> <ExternalLink className="text-muted-foreground mt-0.5 size-3.5 shrink-0 self-start opacity-0 transition-opacity group-hover:opacity-100" />
<p className="text-muted-foreground truncate text-xs"> </button>
{citation.domain} );
</p>
</div>
<ExternalLink className="text-muted-foreground mt-0.5 size-3.5 shrink-0 self-start opacity-0 transition-opacity group-hover:opacity-100" />
</button>
);
} }
interface StackedCitationsProps { interface StackedCitationsProps {
id: string; id: string;
citations: SerializableCitation[]; citations: SerializableCitation[];
className?: string; className?: string;
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, const maxIcons = 4;
onNavigate, const visibleCitations = citations.slice(0, maxIcons);
}: StackedCitationsProps) { const remainingCount = Math.max(0, citations.length - maxIcons);
const {
open,
setOpen,
containerRef,
handleMouseEnter,
handleMouseLeave,
handleBlur,
} = useHoverPopover();
const maxIcons = 4;
const visibleCitations = citations.slice(0, maxIcons);
const remainingCount = Math.max(0, citations.length - maxIcons);
const handleClick = (citation: SerializableCitation) => { const handleClick = (citation: SerializableCitation) => {
const href = resolveSafeNavigationHref(citation.href); const href = resolveSafeNavigationHref(citation.href);
if (!href) return; if (!href) return;
if (onNavigate) { if (onNavigate) {
onNavigate(href, citation); onNavigate(href, citation);
} else { } else {
openSafeNavigationHref(href); openSafeNavigationHref(href);
} }
}; };
return ( return (
// biome-ignore lint/a11y/noStaticElementInteractions: blur boundary for popover focus management // biome-ignore lint/a11y/noStaticElementInteractions: blur boundary for popover focus management
<div ref={containerRef} onBlur={handleBlur} className="inline-flex"> <div ref={containerRef} onBlur={handleBlur} className="inline-flex">
<Popover open={open}> <Popover open={open}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button <button
type="button" type="button"
data-tool-ui-id={id} data-tool-ui-id={id}
data-slot="citation-list" data-slot="citation-list"
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") { if (e.key === "Enter" || e.key === " ") {
e.preventDefault(); e.preventDefault();
setOpen(true); setOpen(true);
} }
}} }}
className={cn( className={cn(
"isolate inline-flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2", "isolate inline-flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2",
"bg-muted/40 outline-none", "bg-muted/40 outline-none",
"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 }} >
> {citation.favicon ? (
{citation.favicon ? ( // biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config
// biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config <img
<img src={citation.favicon}
src={citation.favicon} alt=""
alt="" aria-hidden="true"
aria-hidden="true" width={18}
width={18} height={18}
height={18} className="size-4.5 rounded-full object-cover"
className="size-4.5 rounded-full object-cover" />
/> ) : (
) : ( <TypeIcon className="text-muted-foreground size-3" aria-hidden="true" />
<TypeIcon )}
className="text-muted-foreground size-3" </div>
aria-hidden="true" );
/> })}
)} {remainingCount > 0 && (
</div> <div
); className="border-border bg-background dark:border-foreground/20 relative -ml-2 flex size-6 items-center justify-center rounded-full border shadow-xs"
})} style={{ zIndex: 0 }}
{remainingCount > 0 && ( >
<div <span className="text-muted-foreground text-[10px] font-medium tracking-tight">
className="border-border bg-background dark:border-foreground/20 relative -ml-2 flex size-6 items-center justify-center rounded-full border shadow-xs"
style={{ zIndex: 0 }} </span>
> </div>
<span className="text-muted-foreground text-[10px] font-medium tracking-tight"> )}
</div>
</span> <span className="text-muted-foreground text-sm tabular-nums">
</div> {citations.length} source{citations.length !== 1 && "s"}
)} </span>
</div> </button>
<span className="text-muted-foreground text-sm tabular-nums"> </PopoverTrigger>
{citations.length} source{citations.length !== 1 && "s"} <PopoverContent
</span> side="bottom"
</button> align="start"
</PopoverTrigger> className="w-80 p-1"
<PopoverContent onMouseEnter={handleMouseEnter}
side="bottom" onMouseLeave={handleMouseLeave}
align="start" onBlur={handleBlur}
className="w-80 p-1" onEscapeKeyDown={() => setOpen(false)}
onMouseEnter={handleMouseEnter} >
onMouseLeave={handleMouseLeave} <div className="flex max-h-72 flex-col overflow-y-auto">
onBlur={handleBlur} {citations.map((citation) => (
onEscapeKeyDown={() => setOpen(false)} <OverflowItem
> key={citation.id}
<div className="flex max-h-72 flex-col overflow-y-auto"> citation={citation}
{citations.map((citation) => ( onClick={() => handleClick(citation)}
<OverflowItem />
key={citation.id} ))}
citation={citation} </div>
onClick={() => handleClick(citation)} </PopoverContent>
/> </Popover>
))} </div>
</div> );
</PopoverContent>
</Popover>
</div>
);
} }

View file

@ -1,261 +1,248 @@
"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";
const TYPE_ICONS: Record<CitationType, LucideIcon> = { const TYPE_ICONS: Record<CitationType, LucideIcon> = {
webpage: Globe, webpage: Globe,
document: FileText, document: FileText,
article: Newspaper, article: Newspaper,
api: Database, api: Database,
code: Code2, code: Code2,
other: File, other: File,
}; };
function extractDomain(url: string): string | undefined { function extractDomain(url: string): string | undefined {
try { try {
const urlObj = new URL(url); const urlObj = new URL(url);
return urlObj.hostname.replace(/^www\./, ""); return urlObj.hostname.replace(/^www\./, "");
} catch { } catch {
return undefined; return undefined;
} }
} }
function formatDate(isoString: string, locale: string): string { function formatDate(isoString: string, locale: string): string {
try { try {
const date = new Date(isoString); const date = new Date(isoString);
return date.toLocaleDateString(locale, { return date.toLocaleDateString(locale, {
year: "numeric", year: "numeric",
month: "short", month: "short",
}); });
} catch { } catch {
return isoString; return isoString;
} }
} }
function useHoverPopover(delay = 100) { function useHoverPopover(delay = 100) {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null); const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const handleMouseEnter = React.useCallback(() => { const handleMouseEnter = React.useCallback(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current); if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setOpen(true), delay); timeoutRef.current = setTimeout(() => setOpen(true), delay);
}, [delay]); }, [delay]);
const handleMouseLeave = React.useCallback(() => { const handleMouseLeave = React.useCallback(() => {
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(() => {
return () => { return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current); if (timeoutRef.current) clearTimeout(timeoutRef.current);
}; };
}, []); }, []);
return { open, setOpen, handleMouseEnter, handleMouseLeave }; return { open, setOpen, handleMouseEnter, handleMouseLeave };
} }
export interface CitationProps extends SerializableCitation { export interface CitationProps extends SerializableCitation {
variant?: CitationVariant; variant?: CitationVariant;
className?: string; className?: string;
onNavigate?: (href: string, citation: SerializableCitation) => void; onNavigate?: (href: string, citation: SerializableCitation) => void;
} }
export function Citation(props: CitationProps) { export function Citation(props: CitationProps) {
const { variant = "default", className, onNavigate, ...serializable } = props; const { variant = "default", className, onNavigate, ...serializable } = props;
const { const {
id, id,
href: rawHref, href: rawHref,
title, title,
snippet, snippet,
domain: providedDomain, domain: providedDomain,
favicon, favicon,
author, author,
publishedAt, publishedAt,
type = "webpage", type = "webpage",
locale: providedLocale, locale: providedLocale,
} = serializable; } = serializable;
const locale = providedLocale ?? FALLBACK_LOCALE; const locale = providedLocale ?? FALLBACK_LOCALE;
const sanitizedHref = sanitizeHref(rawHref); const sanitizedHref = sanitizeHref(rawHref);
const domain = providedDomain ?? extractDomain(rawHref); const domain = providedDomain ?? extractDomain(rawHref);
const citationData: SerializableCitation = { const citationData: SerializableCitation = {
...serializable, ...serializable,
href: sanitizedHref ?? rawHref, href: sanitizedHref ?? rawHref,
domain, domain,
locale, locale,
}; };
const TypeIcon = TYPE_ICONS[type] ?? Globe; const TypeIcon = TYPE_ICONS[type] ?? Globe;
const handleClick = () => { const handleClick = () => {
if (!sanitizedHref) return; if (!sanitizedHref) return;
if (onNavigate) { if (onNavigate) {
onNavigate(sanitizedHref, citationData); onNavigate(sanitizedHref, citationData);
} else { } else {
openSafeNavigationHref(sanitizedHref); openSafeNavigationHref(sanitizedHref);
} }
}; };
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (sanitizedHref && (e.key === "Enter" || e.key === " ")) { if (sanitizedHref && (e.key === "Enter" || e.key === " ")) {
e.preventDefault(); e.preventDefault();
handleClick(); handleClick();
} }
}; };
const iconElement = favicon ? ( const iconElement = favicon ? (
// biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config // biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config
<img <img
src={favicon} src={favicon}
alt="" alt=""
aria-hidden="true" aria-hidden="true"
width={14} width={14}
height={14} height={14}
className="bg-muted size-3.5 shrink-0 rounded object-cover" className="bg-muted size-3.5 shrink-0 rounded object-cover"
/> />
) : ( ) : (
<TypeIcon className="size-3.5 shrink-0 opacity-60" aria-hidden="true" /> <TypeIcon className="size-3.5 shrink-0 opacity-60" aria-hidden="true" />
); );
const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover(); const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover();
// Inline variant: compact chip with hover popover // Inline variant: compact chip with hover popover
if (variant === "inline") { if (variant === "inline") {
return ( return (
<Popover open={open}> <Popover open={open}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button <button
type="button" type="button"
aria-label={title} aria-label={title}
data-tool-ui-id={id} data-tool-ui-id={id}
data-slot="citation" data-slot="citation"
onClick={handleClick} onClick={handleClick}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
className={cn( className={cn(
"inline-flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1", "inline-flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1",
"bg-muted/60 text-sm outline-none", "bg-muted/60 text-sm outline-none",
"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}
<span className="text-muted-foreground">{domain}</span> <span className="text-muted-foreground">{domain}</span>
</button> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
side="top" side="top"
align="start" align="start"
className="w-72 cursor-pointer p-0" className="w-72 cursor-pointer p-0"
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
onOpenAutoFocus={(e) => e.preventDefault()} onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()}
onClick={handleClick} onClick={handleClick}
> >
<div className="hover:bg-muted/50 flex flex-col gap-2 p-3 transition-colors"> <div className="hover:bg-muted/50 flex flex-col gap-2 p-3 transition-colors">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
{iconElement} {iconElement}
<span className="text-muted-foreground text-xs">{domain}</span> <span className="text-muted-foreground text-xs">{domain}</span>
</div> </div>
<p className="text-sm leading-snug font-medium">{title}</p> <p className="text-sm leading-snug font-medium">{title}</p>
{snippet && ( {snippet && (
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed"> <p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
{snippet} {snippet}
</p> </p>
)} )}
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
); );
} }
// Default variant: full card // Default variant: full card
return ( return (
<article <article
className={cn("relative w-full max-w-md min-w-72", className)} className={cn("relative w-full max-w-md min-w-72", className)}
lang={locale} lang={locale}
data-tool-ui-id={id} data-tool-ui-id={id}
data-slot="citation" data-slot="citation"
> >
{/* biome-ignore lint/a11y/noStaticElementInteractions: div receives role="link" conditionally when href is present */} {/* biome-ignore lint/a11y/noStaticElementInteractions: div receives role="link" conditionally when href is present */}
<div <div
className={cn( className={cn(
"group @container relative isolate flex w-full min-w-0 flex-col overflow-hidden rounded-xl", "group @container relative isolate flex w-full min-w-0 flex-col overflow-hidden rounded-xl",
"border-border bg-card border text-sm shadow-xs", "border-border bg-card border text-sm shadow-xs",
"transition-colors duration-150", "transition-colors duration-150",
sanitizedHref && [ sanitizedHref && [
"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}
tabIndex={sanitizedHref ? 0 : undefined} tabIndex={sanitizedHref ? 0 : undefined}
onKeyDown={sanitizedHref ? handleKeyDown : undefined} onKeyDown={sanitizedHref ? handleKeyDown : undefined}
> >
<div className="flex flex-col gap-2 p-4"> <div className="flex flex-col gap-2 p-4">
<div className="text-muted-foreground flex min-w-0 items-center justify-between gap-1.5 text-xs"> <div className="text-muted-foreground flex min-w-0 items-center justify-between gap-1.5 text-xs">
<div className="flex min-w-0 items-center gap-1.5"> <div className="flex min-w-0 items-center gap-1.5">
{iconElement} {iconElement}
<span className="truncate font-medium">{domain}</span> <span className="truncate font-medium">{domain}</span>
{(author || publishedAt) && ( {(author || publishedAt) && (
<span className="opacity-70"> <span className="opacity-70">
<span className="opacity-60"> </span> <span className="opacity-60"> </span>
{author} {author}
{author && publishedAt && ", "} {author && publishedAt && ", "}
{publishedAt && ( {publishedAt && (
<time dateTime={publishedAt} className="tabular-nums"> <time dateTime={publishedAt} className="tabular-nums">
{formatDate(publishedAt, locale)} {formatDate(publishedAt, locale)}
</time> </time>
)} )}
</span> </span>
)} )}
</div> </div>
{sanitizedHref && ( {sanitizedHref && (
<ExternalLink className="size-3.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" /> <ExternalLink className="size-3.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" />
)} )}
</div> </div>
<h3 className="text-foreground text-[15px] leading-snug font-medium text-pretty"> <h3 className="text-foreground text-[15px] leading-snug font-medium text-pretty">
<span className="group-hover:decoration-foreground/30 line-clamp-2 group-hover:underline group-hover:underline-offset-2"> <span className="group-hover:decoration-foreground/30 line-clamp-2 group-hover:underline group-hover:underline-offset-2">
{title} {title}
</span> </span>
</h3> </h3>
{snippet && ( {snippet && (
<p className="text-muted-foreground text-[13px] leading-relaxed text-pretty"> <p className="text-muted-foreground text-[13px] leading-relaxed text-pretty">
<span className="line-clamp-3">{snippet}</span> <span className="line-clamp-3">{snippet}</span>
</p> </p>
)} )}
</div> </div>
</div> </div>
</article> </article>
); );
} }

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,17 +1,13 @@
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",
"document", "document",
"article", "article",
"api", "api",
"code", "code",
"other", "other",
]); ]);
export type CitationType = z.infer<typeof CitationTypeSchema>; export type CitationType = z.infer<typeof CitationTypeSchema>;
@ -21,18 +17,18 @@ export const CitationVariantSchema = z.enum(["default", "inline", "stacked"]);
export type CitationVariant = z.infer<typeof CitationVariantSchema>; export type CitationVariant = z.infer<typeof CitationVariantSchema>;
export const SerializableCitationSchema = z.object({ export const SerializableCitationSchema = z.object({
id: ToolUIIdSchema, id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(), role: ToolUIRoleSchema.optional(),
receipt: ToolUIReceiptSchema.optional(), receipt: ToolUIReceiptSchema.optional(),
href: z.string().url(), href: z.string().url(),
title: z.string(), title: z.string(),
snippet: z.string().optional(), snippet: z.string().optional(),
domain: z.string().optional(), domain: z.string().optional(),
favicon: z.string().url().optional(), favicon: z.string().url().optional(),
author: z.string().optional(), author: z.string().optional(),
publishedAt: z.string().datetime().optional(), publishedAt: z.string().datetime().optional(),
type: CitationTypeSchema.optional(), type: CitationTypeSchema.optional(),
locale: z.string().optional(), locale: z.string().optional(),
}); });
export type SerializableCitation = z.infer<typeof SerializableCitationSchema>; export type SerializableCitation = z.infer<typeof SerializableCitationSchema>;

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,23 +1,23 @@
import { sanitizeHref } from "./sanitize-href"; import { sanitizeHref } from "./sanitize-href";
export function resolveSafeNavigationHref( export function resolveSafeNavigationHref(
...candidates: Array<string | null | undefined> ...candidates: Array<string | null | undefined>
): string | undefined { ): string | undefined {
for (const candidate of candidates) { for (const candidate of candidates) {
const safeHref = sanitizeHref(candidate ?? undefined); const safeHref = sanitizeHref(candidate ?? undefined);
if (safeHref) { if (safeHref) {
return safeHref; return safeHref;
} }
} }
return undefined; return undefined;
} }
export function openSafeNavigationHref(href: string | undefined): boolean { export function openSafeNavigationHref(href: string | undefined): boolean {
if (!href || typeof window === "undefined") { if (!href || typeof window === "undefined") {
return false; return false;
} }
window.open(href, "_blank", "noopener,noreferrer"); window.open(href, "_blank", "noopener,noreferrer");
return true; return true;
} }

View file

@ -1,28 +1,28 @@
export function sanitizeHref(href?: string): string | undefined { export function sanitizeHref(href?: string): string | undefined {
if (!href) return undefined; if (!href) return undefined;
const candidate = href.trim(); const candidate = href.trim();
if (!candidate) return undefined; if (!candidate) return undefined;
if ( if (
candidate.startsWith("/") || candidate.startsWith("/") ||
candidate.startsWith("./") || candidate.startsWith("./") ||
candidate.startsWith("../") || candidate.startsWith("../") ||
candidate.startsWith("?") || candidate.startsWith("?") ||
candidate.startsWith("#") candidate.startsWith("#")
) { ) {
if (candidate.startsWith("//")) return undefined; if (candidate.startsWith("//")) return undefined;
// eslint-disable-next-line no-control-regex -- intentionally matching control characters // eslint-disable-next-line no-control-regex -- intentionally matching control characters
if (/[\u0000-\u001F\u007F]/.test(candidate)) return undefined; if (/[\u0000-\u001F\u007F]/.test(candidate)) return undefined;
return candidate; return candidate;
} }
try { try {
const url = new URL(candidate); const url = new URL(candidate);
if (url.protocol === "http:" || url.protocol === "https:") { if (url.protocol === "http:" || url.protocol === "https:") {
return url.toString(); return url.toString();
} }
} catch { } catch {
return undefined; return undefined;
} }
return undefined; return undefined;
} }

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:
@ -30,21 +30,16 @@ export type ToolUIId = z.infer<typeof ToolUIIdSchema>;
* Primary role of a Tool UI surface in a chat context. * Primary role of a Tool UI surface in a chat context.
*/ */
export const ToolUIRoleSchema = z.enum([ export const ToolUIRoleSchema = z.enum([
"information", "information",
"decision", "decision",
"control", "control",
"state", "state",
"composite", "composite",
]); ]);
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>;
@ -52,10 +47,10 @@ export type ToolUIReceiptOutcome = z.infer<typeof ToolUIReceiptOutcomeSchema>;
* Optional receipt metadata: a durable summary of an outcome. * Optional receipt metadata: a durable summary of an outcome.
*/ */
export const ToolUIReceiptSchema = z.object({ export const ToolUIReceiptSchema = z.object({
outcome: ToolUIReceiptOutcomeSchema, outcome: ToolUIReceiptOutcomeSchema,
summary: z.string().min(1), summary: z.string().min(1),
identifiers: z.record(z.string(), z.string()).optional(), identifiers: z.record(z.string(), z.string()).optional(),
at: z.string().datetime(), at: z.string().datetime(),
}); });
export type ToolUIReceipt = z.infer<typeof ToolUIReceiptSchema>; export type ToolUIReceipt = z.infer<typeof ToolUIReceiptSchema>;
@ -64,30 +59,28 @@ export type ToolUIReceipt = z.infer<typeof ToolUIReceiptSchema>;
* Base schema for Tool UI payloads (id + optional role/receipt). * Base schema for Tool UI payloads (id + optional role/receipt).
*/ */
export const ToolUISurfaceSchema = z.object({ export const ToolUISurfaceSchema = z.object({
id: ToolUIIdSchema, id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(), role: ToolUIRoleSchema.optional(),
receipt: ToolUIReceiptSchema.optional(), receipt: ToolUIReceiptSchema.optional(),
}); });
export type ToolUISurface = z.infer<typeof ToolUISurfaceSchema>; export type ToolUISurface = z.infer<typeof ToolUISurfaceSchema>;
export const ActionSchema = z.object({ export const ActionSchema = z.object({
id: z.string().min(1), id: z.string().min(1),
label: z.string().min(1), label: z.string().min(1),
/** /**
* Canonical narration the assistant can use after this action is taken. * Canonical narration the assistant can use after this action is taken.
* *
* Example: "I exported the table as CSV." / "I opened the link in a new tab." * Example: "I exported the table as CSV." / "I opened the link in a new tab."
*/ */
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"]) icon: z.custom<ReactNode>().optional(),
.optional(), loading: z.boolean().optional(),
icon: z.custom<ReactNode>().optional(), disabled: z.boolean().optional(),
loading: z.boolean().optional(), shortcut: z.string().optional(),
disabled: z.boolean().optional(),
shortcut: z.string().optional(),
}); });
export type Action = z.infer<typeof ActionSchema>; export type Action = z.infer<typeof ActionSchema>;
@ -95,65 +88,62 @@ export type LocalAction = Action;
export type DecisionAction = Action; export type DecisionAction = Action;
export const DecisionResultSchema = z.object({ export const DecisionResultSchema = z.object({
kind: z.literal("decision"), kind: z.literal("decision"),
version: z.literal(1), version: z.literal(1),
decisionId: z.string().min(1), decisionId: z.string().min(1),
actionId: z.string().min(1), actionId: z.string().min(1),
actionLabel: z.string().min(1), actionLabel: z.string().min(1),
at: z.string().datetime(), at: z.string().datetime(),
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>,
>(args: { >(args: {
decisionId: string; decisionId: string;
action: { id: string; label: string }; action: { id: string; label: string };
payload?: TPayload; payload?: TPayload;
}): DecisionResult<TPayload> { }): DecisionResult<TPayload> {
return { return {
kind: "decision", kind: "decision",
version: 1, version: 1,
decisionId: args.decisionId, decisionId: args.decisionId,
actionId: args.action.id, actionId: args.action.id,
actionLabel: args.action.label, actionLabel: args.action.label,
at: new Date().toISOString(), at: new Date().toISOString(),
payload: args.payload, payload: args.payload,
}; };
} }
export const ActionButtonsPropsSchema = z.object({ export const ActionButtonsPropsSchema = z.object({
actions: z.array(ActionSchema).min(1), actions: z.array(ActionSchema).min(1),
align: z.enum(["left", "center", "right"]).optional(), align: z.enum(["left", "center", "right"]).optional(),
confirmTimeout: z.number().positive().optional(), confirmTimeout: z.number().positive().optional(),
className: z.string().optional(), className: z.string().optional(),
}); });
export const SerializableActionSchema = ActionSchema.omit({ icon: true }); export const SerializableActionSchema = ActionSchema.omit({ icon: true });
export const SerializableActionsSchema = ActionButtonsPropsSchema.extend({ export const SerializableActionsSchema = ActionButtonsPropsSchema.extend({
actions: z.array(SerializableActionSchema), actions: z.array(SerializableActionSchema),
}).omit({ className: true }); }).omit({ className: true });
export interface ActionsConfig { export interface ActionsConfig {
items: Action[]; items: Action[];
align?: "left" | "center" | "right"; align?: "left" | "center" | "right";
confirmTimeout?: number; confirmTimeout?: number;
} }
export const SerializableActionsConfigSchema = z.object({ export const SerializableActionsConfigSchema = z.object({
items: z.array(SerializableActionSchema).min(1), items: z.array(SerializableActionSchema).min(1),
align: z.enum(["left", "center", "right"]).optional(), align: z.enum(["left", "center", "right"]).optional(),
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) {