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_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:

View file

@ -81,7 +81,8 @@ def create_create_onedrive_file_tool(
select(SearchSourceConnector).filter(
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type == SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
)
)
connectors = result.scalars().all()
@ -95,12 +96,14 @@ def create_create_onedrive_file_tool(
accounts = []
for c in connectors:
cfg = c.config or {}
accounts.append({
"id": c.id,
"name": c.name,
"user_email": cfg.get("user_email"),
"auth_expired": cfg.get("auth_expired", False),
})
accounts.append(
{
"id": c.id,
"name": c.name,
"user_email": cfg.get("user_email"),
"auth_expired": cfg.get("auth_expired", False),
}
)
if all(a.get("auth_expired") for a in accounts):
return {
@ -119,16 +122,22 @@ def create_create_onedrive_file_tool(
client = OneDriveClient(session=db_session, connector_id=cid)
items, err = await client.list_children("root")
if err:
logger.warning("Failed to list folders for connector %s: %s", cid, err)
logger.warning(
"Failed to list folders for connector %s: %s", cid, err
)
parent_folders[cid] = []
else:
parent_folders[cid] = [
{"folder_id": item["id"], "name": item["name"]}
for item in items
if item.get("folder") is not None and item.get("id") and item.get("name")
if item.get("folder") is not None
and item.get("id")
and item.get("name")
]
except Exception:
logger.warning("Error fetching folders for connector %s", cid, exc_info=True)
logger.warning(
"Error fetching folders for connector %s", cid, exc_info=True
)
parent_folders[cid] = []
context: dict[str, Any] = {
@ -152,8 +161,12 @@ def create_create_onedrive_file_tool(
}
)
decisions_raw = approval.get("decisions", []) if isinstance(approval, dict) else []
decisions = decisions_raw if isinstance(decisions_raw, list) else [decisions_raw]
decisions_raw = (
approval.get("decisions", []) if isinstance(approval, dict) else []
)
decisions = (
decisions_raw if isinstance(decisions_raw, list) else [decisions_raw]
)
decisions = [d for d in decisions if isinstance(d, dict)]
if not decisions:
return {"status": "error", "message": "No approval decision received"}
@ -192,7 +205,8 @@ def create_create_onedrive_file_tool(
SearchSourceConnector.id == final_connector_id,
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type == SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
)
)
connector = result.scalars().first()
@ -200,7 +214,10 @@ def create_create_onedrive_file_tool(
connector = connectors[0]
if not connector:
return {"status": "error", "message": "Selected OneDrive connector is invalid."}
return {
"status": "error",
"message": "Selected OneDrive connector is invalid.",
}
docx_bytes = _markdown_to_docx(final_content or "")
@ -212,7 +229,9 @@ def create_create_onedrive_file_tool(
mime_type=DOCX_MIME,
)
logger.info(f"OneDrive file created: id={created.get('id')}, name={created.get('name')}")
logger.info(
f"OneDrive file created: id={created.get('id')}, name={created.get('name')}"
)
kb_message_suffix = ""
try:

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
to verify the file name or check if it has been indexed.
"""
logger.info(f"delete_onedrive_file called: file_name='{file_name}', delete_from_kb={delete_from_kb}")
logger.info(
f"delete_onedrive_file called: file_name='{file_name}', delete_from_kb={delete_from_kb}"
)
if db_session is None or search_space_id is None or user_id is None:
return {"status": "error", "message": "OneDrive tool not properly configured."}
return {
"status": "error",
"message": "OneDrive tool not properly configured.",
}
try:
doc_result = await db_session.execute(
@ -89,8 +94,12 @@ def create_delete_onedrive_file_tool(
Document.search_space_id == search_space_id,
Document.document_type == DocumentType.ONEDRIVE_FILE,
func.lower(
cast(Document.document_metadata["onedrive_file_name"], String)
) == func.lower(file_name),
cast(
Document.document_metadata["onedrive_file_name"],
String,
)
)
== func.lower(file_name),
SearchSourceConnector.user_id == user_id,
)
)
@ -110,14 +119,20 @@ def create_delete_onedrive_file_tool(
}
if not document.connector_id:
return {"status": "error", "message": "Document has no associated connector."}
return {
"status": "error",
"message": "Document has no associated connector.",
}
meta = document.document_metadata or {}
file_id = meta.get("onedrive_file_id")
document_id = document.id
if not file_id:
return {"status": "error", "message": "File ID is missing. Please re-index the file."}
return {
"status": "error",
"message": "File ID is missing. Please re-index the file.",
}
conn_result = await db_session.execute(
select(SearchSourceConnector).filter(
@ -125,13 +140,17 @@ def create_delete_onedrive_file_tool(
SearchSourceConnector.id == document.connector_id,
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type == SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
)
)
)
connector = conn_result.scalars().first()
if not connector:
return {"status": "error", "message": "OneDrive connector not found or access denied."}
return {
"status": "error",
"message": "OneDrive connector not found or access denied.",
}
cfg = connector.config or {}
if cfg.get("auth_expired"):
@ -170,8 +189,12 @@ def create_delete_onedrive_file_tool(
}
)
decisions_raw = approval.get("decisions", []) if isinstance(approval, dict) else []
decisions = decisions_raw if isinstance(decisions_raw, list) else [decisions_raw]
decisions_raw = (
approval.get("decisions", []) if isinstance(approval, dict) else []
)
decisions = (
decisions_raw if isinstance(decisions_raw, list) else [decisions_raw]
)
decisions = [d for d in decisions if isinstance(d, dict)]
if not decisions:
return {"status": "error", "message": "No approval decision received"}
@ -206,7 +229,8 @@ def create_delete_onedrive_file_tool(
SearchSourceConnector.id == final_connector_id,
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type == SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.ONEDRIVE_CONNECTOR,
)
)
)
@ -224,10 +248,14 @@ def create_delete_onedrive_file_tool(
f"Deleting OneDrive file: file_id='{final_file_id}', connector={actual_connector_id}"
)
client = OneDriveClient(session=db_session, connector_id=actual_connector_id)
client = OneDriveClient(
session=db_session, connector_id=actual_connector_id
)
await client.trash_file(final_file_id)
logger.info(f"OneDrive file deleted (moved to recycle bin): file_id={final_file_id}")
logger.info(
f"OneDrive file deleted (moved to recycle bin): file_id={final_file_id}"
)
trash_result: dict[str, Any] = {
"status": "success",
@ -272,6 +300,9 @@ def create_delete_onedrive_file_tool(
if isinstance(e, GraphInterrupt):
raise
logger.error(f"Error deleting OneDrive file: {e}", exc_info=True)
return {"status": "error", "message": "Something went wrong while trashing the file. Please try again."}
return {
"status": "error",
"message": "Something went wrong while trashing the file. Please try again.",
}
return delete_onedrive_file

View file

@ -39,7 +39,9 @@ class OneDriveClient:
cfg = connector.config or {}
is_encrypted = cfg.get("_token_encrypted", False)
token_encryption = TokenEncryption(config.SECRET_KEY) if config.SECRET_KEY else None
token_encryption = (
TokenEncryption(config.SECRET_KEY) if config.SECRET_KEY else None
)
access_token = cfg.get("access_token", "")
refresh_token = cfg.get("refresh_token")
@ -206,18 +208,20 @@ class OneDriveClient:
async def download_file_to_disk(self, item_id: str, dest_path: str) -> str | None:
"""Stream file content to disk. Returns error message on failure."""
token = await self._get_valid_token()
async with httpx.AsyncClient(follow_redirects=True) as client:
async with client.stream(
async with (
httpx.AsyncClient(follow_redirects=True) as client,
client.stream(
"GET",
f"{GRAPH_API_BASE}/me/drive/items/{item_id}/content",
headers={"Authorization": f"Bearer {token}"},
timeout=120.0,
) as resp:
if resp.status_code != 200:
return f"Download failed: {resp.status_code}"
with open(dest_path, "wb") as f:
async for chunk in resp.aiter_bytes(chunk_size=5 * 1024 * 1024):
f.write(chunk)
) as resp,
):
if resp.status_code != 200:
return f"Download failed: {resp.status_code}"
with open(dest_path, "wb") as f:
async for chunk in resp.aiter_bytes(chunk_size=5 * 1024 * 1024):
f.write(chunk)
return None
async def create_file(

View file

@ -5,6 +5,7 @@ extension-based, not provider-specific.
"""
import asyncio
import contextlib
import logging
import os
import tempfile
@ -60,7 +61,9 @@ async def download_and_extract_content(
temp_file_path = None
try:
extension = Path(file_name).suffix or get_extension_from_mime(mime_type) or ".bin"
extension = (
Path(file_name).suffix or get_extension_from_mime(mime_type) or ".bin"
)
with tempfile.NamedTemporaryFile(delete=False, suffix=extension) as tmp:
temp_file_path = tmp.name
@ -76,10 +79,8 @@ async def download_and_extract_content(
return None, metadata, str(e)
finally:
if temp_file_path and os.path.exists(temp_file_path):
try:
with contextlib.suppress(Exception):
os.unlink(temp_file_path)
except Exception:
pass
async def _parse_file_to_markdown(file_path: str, filename: str) -> str:
@ -94,9 +95,10 @@ async def _parse_file_to_markdown(file_path: str, filename: str) -> str:
return f.read()
if lower.endswith((".mp3", ".mp4", ".mpeg", ".mpga", ".m4a", ".wav", ".webm")):
from app.config import config as app_config
from litellm import atranscription
from app.config import config as app_config
stt_service_type = (
"local"
if app_config.STT_SERVICE and app_config.STT_SERVICE.startswith("local/")
@ -106,9 +108,13 @@ async def _parse_file_to_markdown(file_path: str, filename: str) -> str:
from app.services.stt_service import stt_service
t0 = time.monotonic()
logger.info(f"[local-stt] START file={filename} thread={threading.current_thread().name}")
logger.info(
f"[local-stt] START file={filename} thread={threading.current_thread().name}"
)
result = await asyncio.to_thread(stt_service.transcribe_file, file_path)
logger.info(f"[local-stt] END file={filename} elapsed={time.monotonic() - t0:.2f}s")
logger.info(
f"[local-stt] END file={filename} elapsed={time.monotonic() - t0:.2f}s"
)
text = result.get("text", "")
else:
with open(file_path, "rb") as audio_file:
@ -150,7 +156,9 @@ async def _parse_file_to_markdown(file_path: str, filename: str) -> str:
parse_with_llamacloud_retry,
)
result = await parse_with_llamacloud_retry(file_path=file_path, estimated_pages=50)
result = await parse_with_llamacloud_retry(
file_path=file_path, estimated_pages=50
)
markdown_documents = await result.aget_markdown_documents(split_by_page=False)
if not markdown_documents:
raise RuntimeError(f"LlamaCloud returned no documents for {filename}")
@ -161,9 +169,13 @@ async def _parse_file_to_markdown(file_path: str, filename: str) -> str:
converter = DocumentConverter()
t0 = time.monotonic()
logger.info(f"[docling] START file={filename} thread={threading.current_thread().name}")
logger.info(
f"[docling] START file={filename} thread={threading.current_thread().name}"
)
result = await asyncio.to_thread(converter.convert, file_path)
logger.info(f"[docling] END file={filename} elapsed={time.monotonic() - t0:.2f}s")
logger.info(
f"[docling] END file={filename} elapsed={time.monotonic() - t0:.2f}s"
)
return result.document.export_to_markdown()
raise RuntimeError(f"Unknown ETL_SERVICE: {app_config.ETL_SERVICE}")

View file

@ -27,7 +27,10 @@ async def list_folder_contents(
if item["isFolder"]:
item.setdefault("mimeType", "application/vnd.ms-folder")
else:
item.setdefault("mimeType", item.get("file", {}).get("mimeType", "application/octet-stream"))
item.setdefault(
"mimeType",
item.get("file", {}).get("mimeType", "application/octet-stream"),
)
items.sort(key=lambda x: (not x["isFolder"], x.get("name", "").lower()))
@ -63,7 +66,9 @@ async def get_files_in_folder(
client, item["id"], include_subfolders=True
)
if sub_error:
logger.warning(f"Error recursing into folder {item.get('name')}: {sub_error}")
logger.warning(
f"Error recursing into folder {item.get('name')}: {sub_error}"
)
continue
files.extend(sub_files)
elif not should_skip_file(item):

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 .notifications_routes import router as notifications_router
from .notion_add_connector_route import router as notion_add_connector_router
from .onedrive_add_connector_route import router as onedrive_add_connector_router
from .podcasts_routes import router as podcasts_router
from .public_chat_routes import router as public_chat_router
from .prompts_routes import router as prompts_router
from .public_chat_routes import router as public_chat_router
from .rbac_routes import router as rbac_router
from .reports_routes import router as reports_router
from .sandbox_routes import router as sandbox_router
@ -44,7 +45,6 @@ from .search_spaces_routes import router as search_spaces_router
from .slack_add_connector_route import router as slack_add_connector_router
from .surfsense_docs_routes import router as surfsense_docs_router
from .teams_add_connector_route import router as teams_add_connector_router
from .onedrive_add_connector_route import router as onedrive_add_connector_router
from .video_presentations_routes import router as video_presentations_router
from .youtube_routes import router as youtube_router

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -13,7 +13,9 @@ _EMBEDDING_DIM = app_config.embedding_model_instance.dimension
pytestmark = pytest.mark.integration
def _onedrive_doc(*, unique_id: str, search_space_id: int, connector_id: int, user_id: str) -> ConnectorDocument:
def _onedrive_doc(
*, unique_id: str, search_space_id: int, connector_id: int, user_id: str
) -> ConnectorDocument:
return ConnectorDocument(
title=f"File {unique_id}.docx",
source_markdown=f"## Document\n\nContent from {unique_id}",
@ -32,7 +34,9 @@ def _onedrive_doc(*, unique_id: str, search_space_id: int, connector_id: int, us
)
@pytest.mark.usefixtures("patched_summarize", "patched_embed_texts", "patched_chunk_text")
@pytest.mark.usefixtures(
"patched_summarize", "patched_embed_texts", "patched_chunk_text"
)
async def test_onedrive_pipeline_creates_ready_document(
db_session, db_search_space, db_connector, db_user, mocker
):
@ -61,7 +65,9 @@ async def test_onedrive_pipeline_creates_ready_document(
assert DocumentStatus.is_state(row.status, DocumentStatus.READY)
@pytest.mark.usefixtures("patched_summarize", "patched_embed_texts", "patched_chunk_text")
@pytest.mark.usefixtures(
"patched_summarize", "patched_embed_texts", "patched_chunk_text"
)
async def test_onedrive_duplicate_content_skipped(
db_session, db_search_space, db_connector, db_user, mocker
):
@ -87,8 +93,6 @@ async def test_onedrive_duplicate_content_skipped(
)
first_doc = result.scalars().first()
assert first_doc is not None
first_id = first_doc.id
doc2 = _onedrive_doc(
unique_id="od-dup-file",
search_space_id=space_id,
@ -97,4 +101,6 @@ async def test_onedrive_duplicate_content_skipped(
)
prepared2 = await service.prepare_for_indexing([doc2])
assert len(prepared2) == 0 or (len(prepared2) == 1 and prepared2[0].existing_document is not None)
assert len(prepared2) == 0 or (
len(prepared2) == 1 and prepared2[0].existing_document is not None
)

View file

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

View file

@ -744,7 +744,11 @@ export function DocumentsTableShell({
</button>
</DropdownMenuTrigger>
<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" />
Open
</DropdownMenuItem>

View file

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

View file

@ -166,13 +166,13 @@ export default function OnboardPage() {
{/* 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">
<LLMConfigForm
searchSpaceId={searchSpaceId}
onSubmit={handleSubmit}
mode="create"
showAdvanced={true}
formId="onboard-config-form"
initialData={{
<LLMConfigForm
searchSpaceId={searchSpaceId}
onSubmit={handleSubmit}
mode="create"
showAdvanced={true}
formId="onboard-config-form"
initialData={{
citations_enabled: true,
use_default_system_instructions: true,
}}
@ -190,9 +190,7 @@ export default function OnboardPage() {
<span className={isSubmitting ? "opacity-0" : ""}>Start Using SurfSense</span>
{isSubmitting && <Spinner size="sm" className="absolute" />}
</Button>
<p className="text-xs text-muted-foreground">
You can add more configurations later
</p>
<p className="text-xs text-muted-foreground">You can add more configurations later</p>
</div>
</div>
</div>

View file

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

View file

@ -3,11 +3,11 @@
import { PenLine, Plus, Sparkles, Trash2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import type { PromptRead } from "@/contracts/types/prompts.types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import type { PromptRead } from "@/contracts/types/prompts.types";
import { promptsApiService } from "@/lib/apis/prompts-api.service";
interface PromptFormData {
@ -99,7 +99,9 @@ export function PromptsContent() {
<div className="space-y-6 min-w-0 overflow-hidden">
<div className="flex items-center justify-between">
<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>
{!showForm && (
<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"
/>
<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>
</div>
@ -153,7 +159,9 @@ export function PromptsContent() {
<select
id="prompt-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"
>
<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 }) => {
return newLLMConfigApiService.deleteConfig({ id: request.id });
},
onSuccess: (_: DeleteNewLLMConfigResponse, request: DeleteNewLLMConfigRequest & { name: string }) => {
onSuccess: (
_: DeleteNewLLMConfigResponse,
request: DeleteNewLLMConfigRequest & { name: string }
) => {
toast.success(`${request.name} deleted`);
queryClient.setQueryData(
cacheKeys.newLLMConfigs.all(Number(searchSpaceId)),

View file

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

View file

@ -7,7 +7,14 @@ import {
useAuiState,
} from "@assistant-ui/react";
import { useAtomValue } from "jotai";
import { CheckIcon, ClipboardPaste, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react";
import {
CheckIcon,
ClipboardPaste,
CopyIcon,
DownloadIcon,
MessageSquare,
RefreshCwIcon,
} from "lucide-react";
import type { FC } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { commentsEnabledAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
@ -41,10 +48,6 @@ import {
CreateGoogleDriveFileToolUI,
DeleteGoogleDriveFileToolUI,
} from "@/components/tool-ui/google-drive";
import {
CreateOneDriveFileToolUI,
DeleteOneDriveFileToolUI,
} from "@/components/tool-ui/onedrive";
import {
CreateJiraIssueToolUI,
DeleteJiraIssueToolUI,
@ -60,6 +63,7 @@ import {
DeleteNotionPageToolUI,
UpdateNotionPageToolUI,
} from "@/components/tool-ui/notion";
import { CreateOneDriveFileToolUI, DeleteOneDriveFileToolUI } from "@/components/tool-ui/onedrive";
import { SandboxExecuteToolUI } from "@/components/tool-ui/sandbox-execute";
import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory";
import { GenerateVideoPresentationToolUI } from "@/components/tool-ui/video-presentation";
@ -117,10 +121,10 @@ const AssistantMessageInner: FC = () => {
create_confluence_page: CreateConfluencePageToolUI,
update_confluence_page: UpdateConfluencePageToolUI,
delete_confluence_page: DeleteConfluencePageToolUI,
web_search: () => null,
link_preview: () => null,
multi_link_preview: () => null,
scrape_webpage: () => null,
web_search: () => null,
link_preview: () => null,
multi_link_preview: () => null,
scrape_webpage: () => null,
},
Fallback: ToolFallback,
},

View file

@ -24,7 +24,9 @@ interface MessageContent {
}
export const CitationMetadataProvider: FC<{ children: ReactNode }> = ({ children }) => {
const content = useAuiState(({ message }) => (message as { content?: MessageContent[] })?.content);
const content = useAuiState(
({ message }) => (message as { content?: MessageContent[] })?.content
);
const metadataMap = useMemo<CitationMetadataMap>(() => {
if (!content || !Array.isArray(content)) return new Map();
@ -51,7 +53,9 @@ export const CitationMetadataProvider: FC<{ children: ReactNode }> = ({ children
}, [content]);
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 (
<Dialog open={isOpen} modal={false} onOpenChange={handleOpenChange}>
{isOpen &&
createPortal(
<div
@ -299,11 +298,11 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
onBack={handleBackFromEdit}
onQuickIndex={(() => {
const cfg = connectorConfig || editingConnector.config;
const isDriveOrOneDrive =
editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
editingConnector.connector_type === "ONEDRIVE_CONNECTOR";
const hasDriveItems = isDriveOrOneDrive
const isDriveOrOneDrive =
editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
editingConnector.connector_type === "ONEDRIVE_CONNECTOR";
const hasDriveItems = isDriveOrOneDrive
? ((cfg?.selected_folders as unknown[]) ?? []).length > 0 ||
((cfg?.selected_files as unknown[]) ?? []).length > 0
: true;

View file

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

View file

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

View file

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

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);
if (!query.startsWith(" ")) {
shouldTriggerAction = true;
@ -575,7 +580,15 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
// Notify parent of change
onChange?.(text, Array.from(mentionedDocs.values()));
}, [getText, mentionedDocs, onChange, onMentionTrigger, onMentionClose, onActionTrigger, onActionClose]);
}, [
getText,
mentionedDocs,
onChange,
onMentionTrigger,
onMentionClose,
onActionTrigger,
onActionClose,
]);
// Handle keydown
const handleKeyDown = useCallback(

View file

@ -395,7 +395,10 @@ const defaultComponents = memoizeMarkdownComponents({
if (!isCodeBlock) {
return (
<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}
>
{children}

View file

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

View file

@ -240,7 +240,9 @@ export function FolderTreeView({
return (
<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-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>
);
}

View file

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

View file

@ -20,7 +20,12 @@ import {
teamDialogAtom,
userSettingsDialogAtom,
} from "@/atoms/settings/settings-dialog.atoms";
import { removeChatTabAtom, resetTabsAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
import {
removeChatTabAtom,
resetTabsAtom,
syncChatTabAtom,
type Tab,
} from "@/atoms/tabs/tabs.atom";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { MorePagesDialog } from "@/components/settings/more-pages-dialog";
import { SearchSpaceSettingsDialog } from "@/components/settings/search-space-settings-dialog";
@ -846,7 +851,9 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
disabled={isRenamingChat || !newChatTitle.trim()}
className="relative"
>
<span className={isRenamingChat ? "opacity-0" : ""}>{tSidebar("rename") || "Rename"}</span>
<span className={isRenamingChat ? "opacity-0" : ""}>
{tSidebar("rename") || "Rename"}
</span>
{isRenamingChat && (
<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 { usePathname } from "next/navigation";
import { hitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { hitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { reportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
import { editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
@ -37,7 +37,8 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
const reportOpen = reportState.isOpen && !!reportState.reportId;
const editorOpen = editorState.isOpen && !!editorState.documentId;
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
const showExpandButton = !isMobile && collapsed && (documentsOpen || reportOpen || editorOpen || hitlEditOpen);
const showExpandButton =
!isMobile && collapsed && (documentsOpen || reportOpen || editorOpen || hitlEditOpen);
const hasTabBar = tabs.length > 1;
const currentThreadState = useAtomValue(currentThreadAtom);
@ -71,7 +72,9 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
</div>
{/* 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 && (
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />
)}

View file

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

View file

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

View file

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

View file

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

View file

@ -58,7 +58,12 @@ export function TabBar({ onTabSwitch, onNewChat, className }: TabBarProps) {
if (tabs.length <= 1) return null;
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
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"

View file

@ -1,14 +1,14 @@
"use client";
import { useCallback, useState } from "react";
import { ImageConfigDialog } from "@/components/shared/image-config-dialog";
import { ModelConfigDialog } from "@/components/shared/model-config-dialog";
import type {
GlobalImageGenConfig,
GlobalNewLLMConfig,
ImageGenerationConfig,
NewLLMConfigPublic,
} 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";
interface ChatHeaderProps {

View file

@ -1,5 +1,6 @@
"use client";
import { useSetAtom } from "jotai";
import {
BookOpen,
Check,
@ -8,11 +9,10 @@ import {
List,
Minimize2,
PenLine,
Plus,
Search,
Zap,
Plus,
} from "lucide-react";
import { useSetAtom } from "jotai";
import {
forwardRef,
useCallback,
@ -53,113 +53,192 @@ const ICONS: Record<string, React.ReactNode> = {
zap: <Zap className="size-3.5" />,
};
const DEFAULT_ACTIONS: { name: string; prompt: string; mode: "transform" | "explore"; icon: string }[] = [
{ name: "Fix grammar", prompt: "Fix the grammar and spelling in the following text. Return only the corrected text, nothing else.\n\n{selection}", mode: "transform", icon: "check" },
{ name: "Make shorter", prompt: "Make the following text more concise while preserving its meaning. Return only the shortened text, nothing else.\n\n{selection}", mode: "transform", icon: "minimize" },
{ name: "Translate", prompt: "Translate the following text to English. If it is already in English, translate it to French. Return only the translation, nothing else.\n\n{selection}", mode: "transform", icon: "languages" },
{ name: "Rewrite", prompt: "Rewrite the following text to improve clarity and readability. Return only the rewritten text, nothing else.\n\n{selection}", mode: "transform", icon: "pen-line" },
{ name: "Summarize", prompt: "Summarize the following text concisely. Return only the summary, nothing else.\n\n{selection}", mode: "transform", icon: "list" },
{ name: "Explain", prompt: "Explain the following text in simple terms:\n\n{selection}", mode: "explore", icon: "book-open" },
{ name: "Ask my knowledge base", prompt: "Search my knowledge base for information related to:\n\n{selection}", mode: "explore", icon: "search" },
{ name: "Look up on the web", prompt: "Search the web for information about:\n\n{selection}", mode: "explore", icon: "globe" },
const DEFAULT_ACTIONS: {
name: string;
prompt: string;
mode: "transform" | "explore";
icon: string;
}[] = [
{
name: "Fix grammar",
prompt:
"Fix the grammar and spelling in the following text. Return only the corrected text, nothing else.\n\n{selection}",
mode: "transform",
icon: "check",
},
{
name: "Make shorter",
prompt:
"Make the following text more concise while preserving its meaning. Return only the shortened text, nothing else.\n\n{selection}",
mode: "transform",
icon: "minimize",
},
{
name: "Translate",
prompt:
"Translate the following text to English. If it is already in English, translate it to French. Return only the translation, nothing else.\n\n{selection}",
mode: "transform",
icon: "languages",
},
{
name: "Rewrite",
prompt:
"Rewrite the following text to improve clarity and readability. Return only the rewritten text, nothing else.\n\n{selection}",
mode: "transform",
icon: "pen-line",
},
{
name: "Summarize",
prompt:
"Summarize the following text concisely. Return only the summary, nothing else.\n\n{selection}",
mode: "transform",
icon: "list",
},
{
name: "Explain",
prompt: "Explain the following text in simple terms:\n\n{selection}",
mode: "explore",
icon: "book-open",
},
{
name: "Ask my knowledge base",
prompt: "Search my knowledge base for information related to:\n\n{selection}",
mode: "explore",
icon: "search",
},
{
name: "Look up on the web",
prompt: "Search the web for information about:\n\n{selection}",
mode: "explore",
icon: "globe",
},
];
export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(
function PromptPicker({ onSelect, onDone, externalSearch = "", containerStyle }, ref) {
const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom);
const [highlightedIndex, setHighlightedIndex] = useState(0);
const [customPrompts, setCustomPrompts] = useState<PromptRead[]>([]);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const shouldScrollRef = useRef(false);
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(function PromptPicker(
{ onSelect, onDone, externalSearch = "", containerStyle },
ref
) {
const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom);
const [highlightedIndex, setHighlightedIndex] = useState(0);
const [customPrompts, setCustomPrompts] = useState<PromptRead[]>([]);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const shouldScrollRef = useRef(false);
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
useEffect(() => {
promptsApiService.list().then(setCustomPrompts).catch(() => {});
}, []);
useEffect(() => {
promptsApiService
.list()
.then(setCustomPrompts)
.catch(() => {});
}, []);
const allActions = useMemo(() => {
const customs = customPrompts.map((a) => ({
name: a.name,
prompt: a.prompt,
mode: a.mode as "transform" | "explore",
icon: a.icon || "zap",
}));
return [...DEFAULT_ACTIONS, ...customs];
}, [customPrompts]);
const allActions = useMemo(() => {
const customs = customPrompts.map((a) => ({
name: a.name,
prompt: a.prompt,
mode: a.mode as "transform" | "explore",
icon: a.icon || "zap",
}));
return [...DEFAULT_ACTIONS, ...customs];
}, [customPrompts]);
const filtered = useMemo(() => {
if (!externalSearch) return allActions;
return allActions.filter((a) =>
a.name.toLowerCase().includes(externalSearch.toLowerCase())
);
}, [allActions, externalSearch]);
const filtered = useMemo(() => {
if (!externalSearch) return allActions;
return allActions.filter((a) => a.name.toLowerCase().includes(externalSearch.toLowerCase()));
}, [allActions, externalSearch]);
// Reset highlight when results change
const prevSearchRef = useRef(externalSearch);
if (prevSearchRef.current !== externalSearch) {
prevSearchRef.current = externalSearch;
if (highlightedIndex !== 0) {
setHighlightedIndex(0);
}
// Reset highlight when results change
const prevSearchRef = useRef(externalSearch);
if (prevSearchRef.current !== externalSearch) {
prevSearchRef.current = externalSearch;
if (highlightedIndex !== 0) {
setHighlightedIndex(0);
}
}
const handleSelect = useCallback(
(index: number) => {
const action = filtered[index];
if (!action) return;
onSelect({ name: action.name, prompt: action.prompt, mode: action.mode });
},
[filtered, onSelect]
);
const handleSelect = useCallback(
(index: number) => {
const action = filtered[index];
if (!action) return;
onSelect({ name: action.name, prompt: action.prompt, mode: action.mode });
},
[filtered, onSelect]
);
// Auto-scroll highlighted item into view
useEffect(() => {
if (!shouldScrollRef.current) return;
shouldScrollRef.current = false;
// Auto-scroll highlighted item into view
useEffect(() => {
if (!shouldScrollRef.current) return;
shouldScrollRef.current = false;
const rafId = requestAnimationFrame(() => {
const item = itemRefs.current.get(highlightedIndex);
const container = scrollContainerRef.current;
if (item && container) {
const itemRect = item.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
if (itemRect.top < containerRect.top || itemRect.bottom > containerRect.bottom) {
item.scrollIntoView({ block: "nearest" });
}
const rafId = requestAnimationFrame(() => {
const item = itemRefs.current.get(highlightedIndex);
const container = scrollContainerRef.current;
if (item && container) {
const itemRect = item.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
if (itemRect.top < containerRect.top || itemRect.bottom > containerRect.bottom) {
item.scrollIntoView({ block: "nearest" });
}
});
}
});
return () => cancelAnimationFrame(rafId);
}, [highlightedIndex]);
return () => cancelAnimationFrame(rafId);
}, [highlightedIndex]);
useImperativeHandle(
ref,
() => ({
selectHighlighted: () => handleSelect(highlightedIndex),
moveUp: () => {
shouldScrollRef.current = true;
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1));
},
moveDown: () => {
shouldScrollRef.current = true;
setHighlightedIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0));
},
}),
[filtered.length, highlightedIndex, handleSelect]
);
useImperativeHandle(
ref,
() => ({
selectHighlighted: () => handleSelect(highlightedIndex),
moveUp: () => {
shouldScrollRef.current = true;
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1));
},
moveDown: () => {
shouldScrollRef.current = true;
setHighlightedIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0));
},
}),
[filtered.length, highlightedIndex, handleSelect]
);
if (filtered.length === 0) return null;
if (filtered.length === 0) return null;
const defaultFiltered = filtered.filter((_, i) => i < DEFAULT_ACTIONS.length);
const customFiltered = filtered.filter((_, i) => i >= DEFAULT_ACTIONS.length);
const defaultFiltered = filtered.filter((_, i) => i < DEFAULT_ACTIONS.length);
const customFiltered = filtered.filter((_, i) => i >= DEFAULT_ACTIONS.length);
return (
<div
className="w-64 rounded-lg border bg-popover shadow-lg overflow-hidden"
style={containerStyle}
>
<div ref={scrollContainerRef} className="max-h-48 overflow-y-auto py-1">
{defaultFiltered.map((action, index) => (
return (
<div
className="w-64 rounded-lg border bg-popover shadow-lg overflow-hidden"
style={containerStyle}
>
<div ref={scrollContainerRef} className="max-h-48 overflow-y-auto py-1">
{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
key={action.name}
ref={(el) => {
@ -174,52 +253,27 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(
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>
</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
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"><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 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>
);
});

View file

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

View file

@ -1,24 +1,15 @@
"use client";
import { useAtomValue } from "jotai";
import {
AlertCircle,
Edit3,
Info,
Plus,
RefreshCw,
Trash2,
Wand2,
} from "lucide-react";
import { AlertCircle, Edit3, Info, Plus, RefreshCw, Trash2, Wand2 } from "lucide-react";
import { useMemo, useState } from "react";
import {
deleteImageGenConfigMutationAtom,
} from "@/atoms/image-gen-config/image-gen-config-mutation.atoms";
import { deleteImageGenConfigMutationAtom } from "@/atoms/image-gen-config/image-gen-config-mutation.atoms";
import {
globalImageGenConfigsAtom,
imageGenConfigsAtom,
} from "@/atoms/image-gen-config/image-gen-config-query.atoms";
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
import { ImageConfigDialog } from "@/components/shared/image-config-dialog";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
AlertDialog,
@ -40,7 +31,6 @@ import type { ImageGenerationConfig } from "@/contracts/types/new-llm-config.typ
import { useMediaQuery } from "@/hooks/use-media-query";
import { getProviderIcon } from "@/lib/provider-icons";
import { cn } from "@/lib/utils";
import { ImageConfigDialog } from "@/components/shared/image-config-dialog";
interface ImageModelManagerProps {
searchSpaceId: number;
@ -196,7 +186,16 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
<Alert className="bg-muted/50 py-3">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<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>
</Alert>
)}
@ -399,10 +398,8 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
onOpenChange={(open) => !open && setConfigToDelete(null)}
>
<AlertDialogContent className="select-none">
<AlertDialogHeader>
<AlertDialogTitle>
Delete Image Model
</AlertDialogTitle>
<AlertDialogHeader>
<AlertDialogTitle>Delete Image Model</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">{configToDelete?.name}</span>?
@ -410,14 +407,14 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
<span className={isDeleting ? "opacity-0" : ""}>Delete</span>
{isDeleting && <Spinner size="sm" className="absolute" />}
</AlertDialogAction>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
<span className={isDeleting ? "opacity-0" : ""}>Delete</span>
{isDeleting && <Spinner size="sm" className="absolute" />}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

View file

@ -14,9 +14,7 @@ import {
} from "lucide-react";
import { useMemo, useState } from "react";
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
import {
deleteNewLLMConfigMutationAtom,
} from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
import { deleteNewLLMConfigMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
import {
globalNewLLMConfigsAtom,
newLLMConfigsAtom,
@ -203,7 +201,12 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
<Alert className="bg-muted/50 py-3">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<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>
</Alert>
)}
@ -433,9 +436,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
>
<AlertDialogContent className="select-none">
<AlertDialogHeader>
<AlertDialogTitle>
Delete LLM Model
</AlertDialogTitle>
<AlertDialogTitle>Delete LLM Model</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">{configToDelete?.name}</span>? This
@ -449,14 +450,14 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? (
<>
<Spinner size="sm" className="mr-2" />
Deleting
</>
) : (
"Delete"
)}
{isDeleting ? (
<>
<Spinner size="sm" className="mr-2" />
Deleting
</>
) : (
"Delete"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View file

@ -217,9 +217,7 @@ export function ImageConfigDialog({
</div>
<p className="text-sm text-muted-foreground">{getSubtitle()}</p>
{config && mode !== "create" && (
<p className="text-xs font-mono text-muted-foreground/70">
{config.model_name}
</p>
<p className="text-xs font-mono text-muted-foreground/70">{config.model_name}</p>
)}
</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"})`,
}}
>
{isGlobal && config && (
{isGlobal && config && (
<>
<Alert className="mb-5 border-amber-500/30 bg-amber-500/5">
<AlertCircle className="size-4 text-amber-500" />
@ -294,9 +292,7 @@ export function ImageConfigDialog({
<Input
placeholder="Optional description"
value={formData.description}
onChange={(e) =>
setFormData((p) => ({ ...p, description: e.target.value }))
}
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
/>
</div>
@ -337,17 +333,12 @@ export function ImageConfigDialog({
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-full p-0"
align="start"
>
<PopoverContent className="w-full p-0" align="start">
<Command className="bg-transparent">
<CommandInput
placeholder="Search or type model..."
value={formData.model_name}
onValueChange={(val) =>
setFormData((p) => ({ ...p, model_name: val }))
}
onValueChange={(val) => setFormData((p) => ({ ...p, model_name: val }))}
/>
<CommandList>
<CommandEmpty>
@ -368,9 +359,7 @@ export function ImageConfigDialog({
<Check
className={cn(
"mr-2 h-4 w-4",
formData.model_name === m.value
? "opacity-100"
: "opacity-0"
formData.model_name === m.value ? "opacity-100" : "opacity-0"
)}
/>
<span className="font-mono text-sm">{m.value}</span>
@ -388,9 +377,7 @@ export function ImageConfigDialog({
<Input
placeholder="e.g., dall-e-3"
value={formData.model_name}
onChange={(e) =>
setFormData((p) => ({ ...p, model_name: e.target.value }))
}
onChange={(e) => setFormData((p) => ({ ...p, model_name: e.target.value }))}
/>
)}
</div>
@ -420,9 +407,7 @@ export function ImageConfigDialog({
<Input
placeholder="2024-02-15-preview"
value={formData.api_version}
onChange={(e) =>
setFormData((p) => ({ ...p, api_version: e.target.value }))
}
onChange={(e) => setFormData((p) => ({ ...p, api_version: e.target.value }))}
/>
</div>
)}
@ -442,25 +427,25 @@ export function ImageConfigDialog({
Cancel
</Button>
{mode === "create" || (mode === "edit" && !isGlobal) ? (
<Button
onClick={handleSubmit}
disabled={isSubmitting || !isFormValid}
className="relative text-sm h-9 min-w-[120px]"
>
<span className={isSubmitting ? "opacity-0" : ""}>
{mode === "edit" ? "Save Changes" : "Create & Use"}
</span>
{isSubmitting && <Spinner size="sm" className="absolute" />}
</Button>
) : isGlobal && config ? (
<Button
className="relative text-sm h-9"
onClick={handleUseGlobalConfig}
disabled={isSubmitting}
>
<span className={isSubmitting ? "opacity-0" : ""}>Use This Model</span>
{isSubmitting && <Spinner size="sm" className="absolute" />}
</Button>
<Button
onClick={handleSubmit}
disabled={isSubmitting || !isFormValid}
className="relative text-sm h-9 min-w-[120px]"
>
<span className={isSubmitting ? "opacity-0" : ""}>
{mode === "edit" ? "Save Changes" : "Create & Use"}
</span>
{isSubmitting && <Spinner size="sm" className="absolute" />}
</Button>
) : isGlobal && config ? (
<Button
className="relative text-sm h-9"
onClick={handleUseGlobalConfig}
disabled={isSubmitting}
>
<span className={isSubmitting ? "opacity-0" : ""}>Use This Model</span>
{isSubmitting && <Spinner size="sm" className="absolute" />}
</Button>
) : null}
</div>
</DialogContent>

View file

@ -4,7 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useAtomValue } from "jotai";
import { Check, ChevronDown, ChevronsUpDown } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useForm, type Resolver } from "react-hook-form";
import { type Resolver, useForm } from "react-hook-form";
import { z } from "zod";
import {
defaultSystemInstructionsAtom,
@ -219,26 +219,22 @@ export function LLMConfigForm({
)}
/>
{/* Custom Provider (conditional) */}
{watchProvider === "CUSTOM" && (
<FormField
control={form.control}
name="custom_provider"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Custom Provider Name</FormLabel>
<FormControl>
<Input
placeholder="my-custom-provider"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{/* Custom Provider (conditional) */}
{watchProvider === "CUSTOM" && (
<FormField
control={form.control}
name="custom_provider"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Custom Provider Name</FormLabel>
<FormControl>
<Input placeholder="my-custom-provider" {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{/* Model Name with Combobox */}
<FormField
@ -383,29 +379,29 @@ export function LLMConfigForm({
/>
</div>
{/* Ollama Quick Actions */}
{watchProvider === "OLLAMA" && (
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => form.setValue("api_base", "http://localhost:11434")}
>
localhost:11434
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => form.setValue("api_base", "http://host.docker.internal:11434")}
>
Docker
</Button>
</div>
)}
{/* Ollama Quick Actions */}
{watchProvider === "OLLAMA" && (
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => form.setValue("api_base", "http://localhost:11434")}
>
localhost:11434
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => form.setValue("api_base", "http://host.docker.internal:11434")}
>
Docker
</Button>
</div>
)}
</div>
{/* Advanced Parameters */}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -270,9 +270,7 @@ function ApprovalCard({
)}
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
File Type
</p>
<p className="text-xs font-medium text-muted-foreground">File Type</p>
<Select value="docx" disabled>
<SelectTrigger className="w-full">
<SelectValue />
@ -315,11 +313,25 @@ function ApprovalCard({
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 pt-3">
{(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 && (
<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%)" }}>
<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
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>
@ -329,12 +341,26 @@ function ApprovalCard({
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none">
{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" />
</Button>
)}
{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
</Button>
)}
@ -352,7 +378,9 @@ function ErrorCard({ result }: { result: ErrorResult }) {
<p className="text-sm font-semibold text-destructive">Failed to create OneDrive file</p>
</div>
<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>
);
}
@ -364,7 +392,9 @@ function AuthErrorCard({ result }: { result: AuthErrorResult }) {
<p className="text-sm font-semibold text-destructive">OneDrive authentication expired</p>
</div>
<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>
);
}
@ -373,7 +403,9 @@ function SuccessCard({ result }: { result: SuccessResult }) {
return (
<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 || "OneDrive file created successfully"}</p>
<p className="text-sm font-semibold text-foreground">
{result.message || "OneDrive file created successfully"}
</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-2 text-xs">
@ -383,7 +415,14 @@ function SuccessCard({ result }: { result: SuccessResult }) {
</div>
{result.web_url && (
<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>
@ -391,12 +430,31 @@ function SuccessCard({ result }: { result: SuccessResult }) {
);
}
export const CreateOneDriveFileToolUI = ({ args, result }: ToolCallMessagePartProps<{ name: string; content?: string }, CreateOneDriveFileResult>) => {
export const CreateOneDriveFileToolUI = ({
args,
result,
}: ToolCallMessagePartProps<{ name: string; content?: string }, CreateOneDriveFileResult>) => {
if (!result) return null;
if (isInterruptResult(result)) {
return <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 (isErrorResult(result)) return <ErrorCard result={result} />;
return <SuccessCard result={result as SuccessResult} />;

View file

@ -31,29 +31,76 @@ interface InterruptResult {
context?: { account?: OneDriveAccount; file?: OneDriveFile; error?: string };
}
interface SuccessResult { status: "success"; file_id: string; message?: string; deleted_from_kb?: boolean }
interface ErrorResult { status: "error"; message: string }
interface NotFoundResult { status: "not_found"; message: string }
interface AuthErrorResult { status: "auth_error"; message: string; connector_type?: string }
interface SuccessResult {
status: "success";
file_id: string;
message?: string;
deleted_from_kb?: boolean;
}
interface ErrorResult {
status: "error";
message: string;
}
interface NotFoundResult {
status: "not_found";
message: string;
}
interface AuthErrorResult {
status: "auth_error";
message: string;
connector_type?: string;
}
type DeleteOneDriveFileResult = InterruptResult | SuccessResult | ErrorResult | NotFoundResult | AuthErrorResult;
type DeleteOneDriveFileResult =
| InterruptResult
| SuccessResult
| ErrorResult
| NotFoundResult
| AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return typeof result === "object" && result !== null && "__interrupt__" in result && (result as InterruptResult).__interrupt__ === true;
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
function isErrorResult(result: unknown): result is ErrorResult {
return typeof result === "object" && result !== null && "status" in result && (result as ErrorResult).status === "error";
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as ErrorResult).status === "error"
);
}
function isNotFoundResult(result: unknown): result is NotFoundResult {
return typeof result === "object" && result !== null && "status" in result && (result as NotFoundResult).status === "not_found";
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as NotFoundResult).status === "not_found"
);
}
function isAuthErrorResult(result: unknown): result is AuthErrorResult {
return typeof result === "object" && result !== null && "status" in result && (result as AuthErrorResult).status === "auth_error";
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as AuthErrorResult).status === "auth_error"
);
}
function ApprovalCard({ interruptData, onDecision }: {
function ApprovalCard({
interruptData,
onDecision,
}: {
interruptData: InterruptResult;
onDecision: (decision: { type: "approve" | "reject"; message?: string; edited_action?: { name: string; args: Record<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 [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>
<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>
{phase === "processing" ? (
<TextShimmerLoader text="Trashing file" size="sm" />
@ -96,7 +147,9 @@ function ApprovalCard({ interruptData, onDecision }: {
) : 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">Requires your approval to proceed</p>
<p className="text-xs text-muted-foreground mt-0.5">
Requires your approval to proceed
</p>
)}
</div>
</div>
@ -112,7 +165,9 @@ function ApprovalCard({ interruptData, onDecision }: {
{account && (
<div className="space-y-2">
<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>
)}
{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="font-medium">{file.name}</div>
{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>
@ -136,12 +198,21 @@ function ApprovalCard({ interruptData, onDecision }: {
<>
<div className="mx-5 h-px bg-border/50" />
<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">
<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">
<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>
</div>
</div>
@ -152,8 +223,20 @@ function ApprovalCard({ interruptData, onDecision }: {
<>
<div className="mx-5 h-px bg-border/50" />
<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" variant="ghost" className="rounded-lg text-muted-foreground" onClick={() => { setRejected(); onDecision({ type: "reject", message: "User rejected the action." }); }}>Reject</Button>
<Button size="sm" className="rounded-lg gap-1.5" onClick={handleApprove}>
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>
</>
)}
@ -164,9 +247,13 @@ function ApprovalCard({ interruptData, onDecision }: {
function ErrorCard({ result }: { result: ErrorResult }) {
return (
<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="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>
);
}
@ -185,9 +272,13 @@ function NotFoundCard({ result }: { result: NotFoundResult }) {
function AuthErrorCard({ result }: { result: AuthErrorResult }) {
return (
<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="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>
);
}
@ -195,23 +286,51 @@ function AuthErrorCard({ result }: { result: AuthErrorResult }) {
function SuccessCard({ result }: { result: SuccessResult }) {
return (
<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 && (
<>
<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>
);
}
export const DeleteOneDriveFileToolUI = ({ result }: ToolCallMessagePartProps<{ file_name: string; delete_from_kb?: boolean }, DeleteOneDriveFileResult>) => {
export const DeleteOneDriveFileToolUI = ({
result,
}: ToolCallMessagePartProps<
{ file_name: string; delete_from_kb?: boolean },
DeleteOneDriveFileResult
>) => {
if (!result) return null;
if (isInterruptResult(result)) {
return <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 (isNotFoundResult(result)) return <NotFoundCard result={result} />;
if (isErrorResult(result)) return <ErrorCard result={result} />;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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