Merge pull request #1036 from AnishSarkar22/fix/chat-ux

feat: citation UI for web links & many UI/UX improvements
This commit is contained in:
Rohan Verma 2026-03-30 15:20:37 -07:00 committed by GitHub
commit 683ca4c6d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
83 changed files with 3550 additions and 2649 deletions

View file

@ -24,7 +24,7 @@ SurfSense 现已支持以下国产 LLM
1. 登录 SurfSense Dashboard 1. 登录 SurfSense Dashboard
2. 进入 **Settings****API Keys** (或 **LLM Configurations**) 2. 进入 **Settings****API Keys** (或 **LLM Configurations**)
3. 点击 **Add New Configuration** 3. 点击 **Add LLM Model**
4. 从 **Provider** 下拉菜单中选择你的国产 LLM 提供商 4. 从 **Provider** 下拉菜单中选择你的国产 LLM 提供商
5. 填写必填字段(见下方各提供商详细配置) 5. 填写必填字段(见下方各提供商详细配置)
6. 点击 **Save** 6. 点击 **Save**

View file

@ -42,7 +42,9 @@ def upgrade() -> None:
) )
""") """)
op.execute("CREATE INDEX ix_prompts_user_id ON prompts (user_id)") op.execute("CREATE INDEX ix_prompts_user_id ON prompts (user_id)")
op.execute("CREATE INDEX ix_prompts_search_space_id ON prompts (search_space_id)") op.execute(
"CREATE INDEX ix_prompts_search_space_id ON prompts (search_space_id)"
)
def downgrade() -> None: def downgrade() -> None:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -33,9 +33,10 @@ from .new_llm_config_routes import router as new_llm_config_router
from .notes_routes import router as notes_router from .notes_routes import router as notes_router
from .notifications_routes import router as notifications_router from .notifications_routes import router as notifications_router
from .notion_add_connector_route import router as notion_add_connector_router from .notion_add_connector_route import router as notion_add_connector_router
from .onedrive_add_connector_route import router as onedrive_add_connector_router
from .podcasts_routes import router as podcasts_router from .podcasts_routes import router as podcasts_router
from .public_chat_routes import router as public_chat_router
from .prompts_routes import router as prompts_router from .prompts_routes import router as prompts_router
from .public_chat_routes import router as public_chat_router
from .rbac_routes import router as rbac_router from .rbac_routes import router as rbac_router
from .reports_routes import router as reports_router from .reports_routes import router as reports_router
from .sandbox_routes import router as sandbox_router from .sandbox_routes import router as sandbox_router
@ -44,7 +45,6 @@ from .search_spaces_routes import router as search_spaces_router
from .slack_add_connector_route import router as slack_add_connector_router from .slack_add_connector_route import router as slack_add_connector_router
from .surfsense_docs_routes import router as surfsense_docs_router from .surfsense_docs_routes import router as surfsense_docs_router
from .teams_add_connector_route import router as teams_add_connector_router from .teams_add_connector_route import router as teams_add_connector_router
from .onedrive_add_connector_route import router as onedrive_add_connector_router
from .video_presentations_routes import router as video_presentations_router from .video_presentations_routes import router as video_presentations_router
from .youtube_routes import router as youtube_router from .youtube_routes import router as youtube_router

View file

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

View file

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

View file

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

View file

@ -1075,6 +1075,37 @@ async def _stream_agent_events(
"thread_id": thread_id_str, "thread_id": thread_id_str,
}, },
) )
elif tool_name == "web_search":
xml = (
tool_output.get("result", str(tool_output))
if isinstance(tool_output, dict)
else str(tool_output)
)
citations: dict[str, dict[str, str]] = {}
for m in re.finditer(
r"<title><!\[CDATA\[(.*?)\]\]></title>\s*<url><!\[CDATA\[(.*?)\]\]></url>",
xml,
):
title, url = m.group(1).strip(), m.group(2).strip()
if url.startswith("http") and url not in citations:
citations[url] = {"title": title}
for m in re.finditer(
r"<chunk\s+id='([^']*)'><!\[CDATA\[([\s\S]*?)\]\]></chunk>",
xml,
):
chunk_url, content = m.group(1).strip(), m.group(2).strip()
if (
chunk_url.startswith("http")
and chunk_url in citations
and content
):
citations[chunk_url]["snippet"] = (
content[:200] + "" if len(content) > 200 else content
)
yield streaming_service.format_tool_output_available(
tool_call_id,
{"status": "completed", "citations": citations},
)
else: else:
yield streaming_service.format_tool_output_available( yield streaming_service.format_tool_output_available(
tool_call_id, tool_call_id,

View file

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

View file

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

View file

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

View file

@ -183,6 +183,10 @@ export function DashboardClientLayout({
); );
} }
if (isOnboardingPage) {
return <>{children}</>;
}
return ( return (
<DocumentUploadDialogProvider> <DocumentUploadDialogProvider>
<OnboardingTour /> <OnboardingTour />

View file

@ -744,7 +744,11 @@ export function DocumentsTableShell({
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48"> <DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => onOpenInTab ? onOpenInTab(doc) : handleViewDocument(doc)}> <DropdownMenuItem
onClick={() =>
onOpenInTab ? onOpenInTab(doc) : handleViewDocument(doc)
}
>
<Eye className="h-4 w-4" /> <Eye className="h-4 w-4" />
Open Open
</DropdownMenuItem> </DropdownMenuItem>
@ -986,9 +990,10 @@ export function DocumentsTableShell({
handleDeleteFromMenu(); handleDeleteFromMenu();
}} }}
disabled={isDeleting} disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
> >
{isDeleting ? <Spinner size="sm" /> : "Delete"} <span className={isDeleting ? "opacity-0" : ""}>Delete</span>
{isDeleting && <Spinner size="sm" className="absolute" />}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@ -1104,9 +1109,10 @@ export function DocumentsTableShell({
handleBulkDelete(); handleBulkDelete();
}} }}
disabled={isBulkDeleting} disabled={isBulkDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
> >
{isBulkDeleting ? <Spinner size="sm" /> : "Delete"} <span className={isBulkDeleting ? "opacity-0" : ""}>Delete</span>
{isBulkDeleting && <Spinner size="sm" className="absolute" />}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>

View file

@ -24,7 +24,7 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import type { Document } from "./types"; import type { Document } from "./types";
const EDITABLE_DOCUMENT_TYPES = ["NOTE"] as const; const EDITABLE_DOCUMENT_TYPES = ["FILE", "NOTE"] as const;
// SURFSENSE_DOCS are system-managed and cannot be deleted // SURFSENSE_DOCS are system-managed and cannot be deleted
const NON_DELETABLE_DOCUMENT_TYPES = ["SURFSENSE_DOCS"] as const; const NON_DELETABLE_DOCUMENT_TYPES = ["SURFSENSE_DOCS"] as const;

View file

@ -33,7 +33,7 @@ import { closeReportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { type AgentCreatedDocument, agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms"; import { type AgentCreatedDocument, agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms";
import { closeEditorPanelAtom } from "@/atoms/editor/editor-panel.atom"; import { closeEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { membersAtom } from "@/atoms/members/members-query.atoms"; import { membersAtom } from "@/atoms/members/members-query.atoms";
import { updateChatTabTitleAtom } from "@/atoms/tabs/tabs.atom"; import { removeChatTabAtom, updateChatTabTitleAtom } from "@/atoms/tabs/tabs.atom";
import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { ThinkingStepsDataUI } from "@/components/assistant-ui/thinking-steps"; import { ThinkingStepsDataUI } from "@/components/assistant-ui/thinking-steps";
import { Thread } from "@/components/assistant-ui/thread"; import { Thread } from "@/components/assistant-ui/thread";
@ -70,6 +70,7 @@ import {
getThreadMessages, getThreadMessages,
type ThreadRecord, type ThreadRecord,
} from "@/lib/chat/thread-persistence"; } from "@/lib/chat/thread-persistence";
import { NotFoundError } from "@/lib/error";
import { import {
trackChatCreated, trackChatCreated,
trackChatError, trackChatError,
@ -131,6 +132,7 @@ function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] {
* Tools that should render custom UI in the chat. * Tools that should render custom UI in the chat.
*/ */
const TOOLS_WITH_UI = new Set([ const TOOLS_WITH_UI = new Set([
"web_search",
"generate_podcast", "generate_podcast",
"generate_report", "generate_report",
"generate_video_presentation", "generate_video_presentation",
@ -194,6 +196,7 @@ export default function NewChatPage() {
const closeReportPanel = useSetAtom(closeReportPanelAtom); const closeReportPanel = useSetAtom(closeReportPanelAtom);
const closeEditorPanel = useSetAtom(closeEditorPanelAtom); const closeEditorPanel = useSetAtom(closeEditorPanelAtom);
const updateChatTabTitle = useSetAtom(updateChatTabTitleAtom); const updateChatTabTitle = useSetAtom(updateChatTabTitleAtom);
const removeChatTab = useSetAtom(removeChatTabAtom);
const setAgentCreatedDocuments = useSetAtom(agentCreatedDocumentsAtom); const setAgentCreatedDocuments = useSetAtom(agentCreatedDocumentsAtom);
// Get current user for author info in shared chats // Get current user for author info in shared chats
@ -323,6 +326,14 @@ export default function NewChatPage() {
// This improves UX (instant load) and avoids orphan threads // This improves UX (instant load) and avoids orphan threads
} catch (error) { } catch (error) {
console.error("[NewChatPage] Failed to initialize thread:", error); console.error("[NewChatPage] Failed to initialize thread:", error);
if (urlChatId > 0 && error instanceof NotFoundError) {
removeChatTab(urlChatId);
if (typeof window !== "undefined") {
window.history.replaceState(null, "", `/dashboard/${searchSpaceId}/new-chat`);
}
toast.error("This chat was deleted.");
return;
}
// Keep threadId as null - don't use Date.now() as it creates an invalid ID // Keep threadId as null - don't use Date.now() as it creates an invalid ID
// that will cause 404 errors on subsequent API calls // that will cause 404 errors on subsequent API calls
setThreadId(null); setThreadId(null);
@ -338,12 +349,14 @@ export default function NewChatPage() {
setSidebarDocuments, setSidebarDocuments,
closeReportPanel, closeReportPanel,
closeEditorPanel, closeEditorPanel,
removeChatTab,
searchSpaceId,
]); ]);
// Initialize on mount, and re-init when switching search spaces (even if urlChatId is the same) // Initialize on mount, and re-init when switching search spaces (even if urlChatId is the same)
useEffect(() => { useEffect(() => {
initializeThread(); initializeThread();
}, [initializeThread, searchSpaceId]); }, [initializeThread]);
// Prefetch document titles for @ mention picker // Prefetch document titles for @ mention picker
// Runs when user lands on page so data is ready when they type @ // Runs when user lands on page so data is ready when they type @
@ -483,18 +496,17 @@ export default function NewChatPage() {
// Add user message to state // Add user message to state
const userMsgId = `msg-user-${Date.now()}`; const userMsgId = `msg-user-${Date.now()}`;
// Include author metadata for shared chats // Always include author metadata so the UI layer can decide visibility
const authorMetadata = const authorMetadata = currentUser
currentThread?.visibility === "SEARCH_SPACE" && currentUser ? {
? { custom: {
custom: { author: {
author: { displayName: currentUser.display_name ?? null,
displayName: currentUser.display_name ?? null, avatarUrl: currentUser.avatar_url ?? null,
avatarUrl: currentUser.avatar_url ?? null,
},
}, },
} },
: undefined; }
: undefined;
const userMessage: ThreadMessageLike = { const userMessage: ThreadMessageLike = {
id: userMsgId, id: userMsgId,
@ -654,62 +666,62 @@ export default function NewChatPage() {
const scheduleFlush = () => batcher.schedule(flushMessages); const scheduleFlush = () => batcher.schedule(flushMessages);
for await (const parsed of readSSEStream(response)) { for await (const parsed of readSSEStream(response)) {
switch (parsed.type) { switch (parsed.type) {
case "text-delta": case "text-delta":
appendText(contentPartsState, parsed.delta); appendText(contentPartsState, parsed.delta);
scheduleFlush(); scheduleFlush();
break; break;
case "tool-input-start": case "tool-input-start":
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {}); addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
batcher.flush(); batcher.flush();
break; break;
case "tool-input-available": { case "tool-input-available": {
if (toolCallIndices.has(parsed.toolCallId)) { if (toolCallIndices.has(parsed.toolCallId)) {
updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} }); updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} });
} else { } else {
addToolCall( addToolCall(
contentPartsState, contentPartsState,
TOOLS_WITH_UI, TOOLS_WITH_UI,
parsed.toolCallId, parsed.toolCallId,
parsed.toolName, parsed.toolName,
parsed.input || {} parsed.input || {}
); );
}
batcher.flush();
break;
} }
batcher.flush();
break;
}
case "tool-output-available": { case "tool-output-available": {
updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output }); updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output });
markInterruptsCompleted(contentParts); markInterruptsCompleted(contentParts);
if (parsed.output?.status === "pending" && parsed.output?.podcast_id) { if (parsed.output?.status === "pending" && parsed.output?.podcast_id) {
const idx = toolCallIndices.get(parsed.toolCallId); const idx = toolCallIndices.get(parsed.toolCallId);
if (idx !== undefined) { if (idx !== undefined) {
const part = contentParts[idx]; const part = contentParts[idx];
if (part?.type === "tool-call" && part.toolName === "generate_podcast") { if (part?.type === "tool-call" && part.toolName === "generate_podcast") {
setActivePodcastTaskId(String(parsed.output.podcast_id)); setActivePodcastTaskId(String(parsed.output.podcast_id));
}
} }
} }
batcher.flush();
break;
} }
batcher.flush();
break;
}
case "data-thinking-step": { case "data-thinking-step": {
const stepData = parsed.data as ThinkingStepData; const stepData = parsed.data as ThinkingStepData;
if (stepData?.id) { if (stepData?.id) {
currentThinkingSteps.set(stepData.id, stepData); currentThinkingSteps.set(stepData.id, stepData);
const didUpdate = updateThinkingSteps(contentPartsState, currentThinkingSteps); const didUpdate = updateThinkingSteps(contentPartsState, currentThinkingSteps);
if (didUpdate) { if (didUpdate) {
scheduleFlush(); scheduleFlush();
}
} }
break;
} }
break;
}
case "data-thread-title-update": { case "data-thread-title-update": {
const titleData = parsed.data as { threadId: number; title: string }; const titleData = parsed.data as { threadId: number; title: string };
if (titleData?.title && titleData?.threadId === currentThreadId) { if (titleData?.title && titleData?.threadId === currentThreadId) {
setCurrentThread((prev) => (prev ? { ...prev, title: titleData.title } : prev)); setCurrentThread((prev) => (prev ? { ...prev, title: titleData.title } : prev));
@ -882,7 +894,6 @@ export default function NewChatPage() {
setMessageDocumentsMap, setMessageDocumentsMap,
setAgentCreatedDocuments, setAgentCreatedDocuments,
queryClient, queryClient,
currentThread,
currentUser, currentUser,
disabledTools, disabledTools,
updateChatTabTitle, updateChatTabTitle,
@ -1001,7 +1012,7 @@ export default function NewChatPage() {
throw new Error(`Backend error: ${response.status}`); throw new Error(`Backend error: ${response.status}`);
} }
const flushMessages = () => { const flushMessages = () => {
setMessages((prev) => setMessages((prev) =>
prev.map((m) => prev.map((m) =>
m.id === assistantMsgId m.id === assistantMsgId
@ -1013,55 +1024,55 @@ export default function NewChatPage() {
const scheduleFlush = () => batcher.schedule(flushMessages); const scheduleFlush = () => batcher.schedule(flushMessages);
for await (const parsed of readSSEStream(response)) { for await (const parsed of readSSEStream(response)) {
switch (parsed.type) { switch (parsed.type) {
case "text-delta": case "text-delta":
appendText(contentPartsState, parsed.delta); appendText(contentPartsState, parsed.delta);
scheduleFlush(); scheduleFlush();
break; break;
case "tool-input-start": case "tool-input-start":
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {}); addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
batcher.flush(); batcher.flush();
break; break;
case "tool-input-available": case "tool-input-available":
if (toolCallIndices.has(parsed.toolCallId)) { if (toolCallIndices.has(parsed.toolCallId)) {
updateToolCall(contentPartsState, parsed.toolCallId, { updateToolCall(contentPartsState, parsed.toolCallId, {
args: parsed.input || {}, args: parsed.input || {},
}); });
} else { } else {
addToolCall( addToolCall(
contentPartsState, contentPartsState,
TOOLS_WITH_UI, TOOLS_WITH_UI,
parsed.toolCallId, parsed.toolCallId,
parsed.toolName, parsed.toolName,
parsed.input || {} parsed.input || {}
); );
}
batcher.flush();
break;
case "tool-output-available":
updateToolCall(contentPartsState, parsed.toolCallId, {
result: parsed.output,
});
markInterruptsCompleted(contentParts);
batcher.flush();
break;
case "data-thinking-step": {
const stepData = parsed.data as ThinkingStepData;
if (stepData?.id) {
currentThinkingSteps.set(stepData.id, stepData);
const didUpdate = updateThinkingSteps(contentPartsState, currentThinkingSteps);
if (didUpdate) {
scheduleFlush();
} }
} batcher.flush();
break; break;
}
case "data-interrupt-request": { case "tool-output-available":
updateToolCall(contentPartsState, parsed.toolCallId, {
result: parsed.output,
});
markInterruptsCompleted(contentParts);
batcher.flush();
break;
case "data-thinking-step": {
const stepData = parsed.data as ThinkingStepData;
if (stepData?.id) {
currentThinkingSteps.set(stepData.id, stepData);
const didUpdate = updateThinkingSteps(contentPartsState, currentThinkingSteps);
if (didUpdate) {
scheduleFlush();
}
}
break;
}
case "data-interrupt-request": {
const interruptData = parsed.data as Record<string, unknown>; const interruptData = parsed.data as Record<string, unknown>;
const actionRequests = (interruptData.action_requests ?? []) as Array<{ const actionRequests = (interruptData.action_requests ?? []) as Array<{
name: string; name: string;
@ -1319,7 +1330,7 @@ export default function NewChatPage() {
throw new Error(`Backend error: ${response.status}`); throw new Error(`Backend error: ${response.status}`);
} }
const flushMessages = () => { const flushMessages = () => {
setMessages((prev) => setMessages((prev) =>
prev.map((m) => prev.map((m) =>
m.id === assistantMsgId m.id === assistantMsgId
@ -1331,63 +1342,63 @@ export default function NewChatPage() {
const scheduleFlush = () => batcher.schedule(flushMessages); const scheduleFlush = () => batcher.schedule(flushMessages);
for await (const parsed of readSSEStream(response)) { for await (const parsed of readSSEStream(response)) {
switch (parsed.type) { switch (parsed.type) {
case "text-delta": case "text-delta":
appendText(contentPartsState, parsed.delta); appendText(contentPartsState, parsed.delta);
scheduleFlush(); scheduleFlush();
break; break;
case "tool-input-start": case "tool-input-start":
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {}); addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
batcher.flush(); batcher.flush();
break; break;
case "tool-input-available": case "tool-input-available":
if (toolCallIndices.has(parsed.toolCallId)) { if (toolCallIndices.has(parsed.toolCallId)) {
updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} }); updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} });
} else { } else {
addToolCall( addToolCall(
contentPartsState, contentPartsState,
TOOLS_WITH_UI, TOOLS_WITH_UI,
parsed.toolCallId, parsed.toolCallId,
parsed.toolName, parsed.toolName,
parsed.input || {} parsed.input || {}
); );
} }
batcher.flush(); batcher.flush();
break; break;
case "tool-output-available": case "tool-output-available":
updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output }); updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output });
markInterruptsCompleted(contentParts); markInterruptsCompleted(contentParts);
if (parsed.output?.status === "pending" && parsed.output?.podcast_id) { if (parsed.output?.status === "pending" && parsed.output?.podcast_id) {
const idx = toolCallIndices.get(parsed.toolCallId); const idx = toolCallIndices.get(parsed.toolCallId);
if (idx !== undefined) { if (idx !== undefined) {
const part = contentParts[idx]; const part = contentParts[idx];
if (part?.type === "tool-call" && part.toolName === "generate_podcast") { if (part?.type === "tool-call" && part.toolName === "generate_podcast") {
setActivePodcastTaskId(String(parsed.output.podcast_id)); setActivePodcastTaskId(String(parsed.output.podcast_id));
}
} }
} }
} batcher.flush();
batcher.flush(); break;
break;
case "data-thinking-step": { case "data-thinking-step": {
const stepData = parsed.data as ThinkingStepData; const stepData = parsed.data as ThinkingStepData;
if (stepData?.id) { if (stepData?.id) {
currentThinkingSteps.set(stepData.id, stepData); currentThinkingSteps.set(stepData.id, stepData);
const didUpdate = updateThinkingSteps(contentPartsState, currentThinkingSteps); const didUpdate = updateThinkingSteps(contentPartsState, currentThinkingSteps);
if (didUpdate) { if (didUpdate) {
scheduleFlush(); scheduleFlush();
}
} }
break;
} }
break;
}
case "error": case "error":
throw new Error(parsed.errorText || "Server error"); throw new Error(parsed.errorText || "Server error");
}
} }
}
batcher.flush(); batcher.flush();
@ -1536,4 +1547,4 @@ export default function NewChatPage() {
</div> </div>
</AssistantRuntimeProvider> </AssistantRuntimeProvider>
); );
} }

View file

@ -1,7 +1,6 @@
"use client"; "use client";
import { useAtomValue, useSetAtom } from "jotai"; import { useAtomValue } from "jotai";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@ -13,19 +12,17 @@ import {
globalNewLLMConfigsAtom, globalNewLLMConfigsAtom,
llmPreferencesAtom, llmPreferencesAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms"; } from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { Logo } from "@/components/Logo"; import { Logo } from "@/components/Logo";
import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form"; import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { getBearerToken, redirectToLogin } from "@/lib/auth-utils"; import { getBearerToken, redirectToLogin } from "@/lib/auth-utils";
export default function OnboardPage() { export default function OnboardPage() {
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const searchSpaceId = Number(params.search_space_id); const searchSpaceId = Number(params.search_space_id);
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
// Queries // Queries
const { const {
data: globalConfigs = [], data: globalConfigs = [],
@ -62,14 +59,12 @@ export default function OnboardPage() {
preferences.document_summary_llm_id !== null && preferences.document_summary_llm_id !== null &&
preferences.document_summary_llm_id !== undefined; preferences.document_summary_llm_id !== undefined;
// If onboarding is already complete, redirect immediately
useEffect(() => { useEffect(() => {
if (!preferencesLoading && isOnboardingComplete) { if (!preferencesLoading && isOnboardingComplete) {
router.push(`/dashboard/${searchSpaceId}/new-chat`); router.push(`/dashboard/${searchSpaceId}/new-chat`);
} }
}, [preferencesLoading, isOnboardingComplete, router, searchSpaceId]); }, [preferencesLoading, isOnboardingComplete, router, searchSpaceId]);
// Auto-configure if global configs are available
useEffect(() => { useEffect(() => {
const autoConfigureWithGlobal = async () => { const autoConfigureWithGlobal = async () => {
if (hasAttemptedAutoConfig.current) return; if (hasAttemptedAutoConfig.current) return;
@ -77,7 +72,6 @@ export default function OnboardPage() {
if (!globalConfigsLoaded) return; if (!globalConfigsLoaded) return;
if (isOnboardingComplete) return; if (isOnboardingComplete) return;
// Only auto-configure if we have global configs
if (globalConfigs.length > 0) { if (globalConfigs.length > 0) {
hasAttemptedAutoConfig.current = true; hasAttemptedAutoConfig.current = true;
setIsAutoConfiguring(true); setIsAutoConfiguring(true);
@ -97,7 +91,6 @@ export default function OnboardPage() {
description: `Using ${firstGlobalConfig.name}. You can customize this later in Settings.`, description: `Using ${firstGlobalConfig.name}. You can customize this later in Settings.`,
}); });
// Redirect to new-chat
router.push(`/dashboard/${searchSpaceId}/new-chat`); router.push(`/dashboard/${searchSpaceId}/new-chat`);
} catch (error) { } catch (error) {
console.error("Auto-configuration failed:", error); console.error("Auto-configuration failed:", error);
@ -119,13 +112,10 @@ export default function OnboardPage() {
router, router,
]); ]);
// Handle form submission
const handleSubmit = async (formData: LLMConfigFormData) => { const handleSubmit = async (formData: LLMConfigFormData) => {
try { try {
// Create the config
const newConfig = await createConfig(formData); const newConfig = await createConfig(formData);
// Auto-assign to all roles
await updatePreferences({ await updatePreferences({
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
data: { data: {
@ -138,7 +128,6 @@ export default function OnboardPage() {
description: "Redirecting to chat...", description: "Redirecting to chat...",
}); });
// Redirect to new-chat
router.push(`/dashboard/${searchSpaceId}/new-chat`); router.push(`/dashboard/${searchSpaceId}/new-chat`);
} catch (error) { } catch (error) {
console.error("Failed to create config:", error); console.error("Failed to create config:", error);
@ -150,124 +139,59 @@ export default function OnboardPage() {
const isSubmitting = isCreating || isUpdatingPreferences; const isSubmitting = isCreating || isUpdatingPreferences;
// Loading state const isLoading = globalConfigsLoading || preferencesLoading || isAutoConfiguring;
if (globalConfigsLoading || preferencesLoading || isAutoConfiguring) { useGlobalLoadingEffect(isLoading);
return (
<div className="min-h-screen bg-gradient-to-b from-background to-muted/20 flex items-center justify-center"> if (isLoading) {
<motion.div return null;
initial={{ opacity: 0, scale: 0.95 }} }
animate={{ opacity: 1, scale: 1 }}
className="text-center space-y-6" if (globalConfigs.length > 0 && !isAutoConfiguring) {
> return null;
<div className="relative"> }
<div className="absolute inset-0 blur-3xl bg-gradient-to-r from-violet-500/20 to-cyan-500/20 rounded-full" />
<div className="relative flex items-center justify-center w-24 h-24 mx-auto rounded-2xl bg-gradient-to-br from-violet-500 to-purple-600 shadow-2xl shadow-violet-500/25"> return (
<Spinner size="xl" className="text-white" /> <div className="h-screen flex flex-col items-center p-4 bg-background dark:bg-neutral-900 select-none overflow-hidden">
</div> <div className="w-full max-w-lg flex flex-col min-h-0 h-full gap-6 py-8">
</div> {/* Header */}
<div className="space-y-2"> <div className="text-center space-y-3 shrink-0">
<h2 className="text-2xl font-bold tracking-tight"> <Logo className="w-12 h-12 mx-auto" />
{isAutoConfiguring ? "Setting up your AI..." : "Loading..."} <div className="space-y-1">
</h2> <h1 className="text-2xl font-semibold tracking-tight">Configure Your AI</h1>
<p className="text-muted-foreground"> <p className="text-sm text-muted-foreground">
{isAutoConfiguring Add your LLM provider to get started with SurfSense
? "Auto-configuring with available settings"
: "Please wait while we check your configuration"}
</p> </p>
</div> </div>
<div className="flex justify-center gap-1"> </div>
{[0, 1, 2].map((i) => (
<motion.div
key={i}
className="w-2 h-2 rounded-full bg-violet-500"
animate={{ scale: [1, 1.5, 1], opacity: [0.5, 1, 0.5] }}
transition={{ duration: 1, repeat: Infinity, delay: i * 0.2 }}
/>
))}
</div>
</motion.div>
</div>
);
}
// If global configs exist but auto-config failed, show simple message {/* Form card */}
if (globalConfigs.length > 0 && !isAutoConfiguring) { <div className="rounded-xl border bg-background dark:bg-neutral-900 flex-1 min-h-0 overflow-y-auto px-6 py-6">
return null; // Will redirect via useEffect <LLMConfigForm
} searchSpaceId={searchSpaceId}
onSubmit={handleSubmit}
mode="create"
showAdvanced={true}
formId="onboard-config-form"
initialData={{
citations_enabled: true,
use_default_system_instructions: true,
}}
/>
</div>
// No global configs - show the config form {/* Footer */}
return ( <div className="text-center space-y-4 shrink-0">
<div className="min-h-screen bg-gradient-to-b from-background via-background to-muted/30"> <Button
<div className="container mx-auto px-4 py-8 md:py-12 max-w-3xl"> type="submit"
<motion.div form="onboard-config-form"
initial={{ opacity: 0, y: 20 }} disabled={isSubmitting}
animate={{ opacity: 1, y: 0 }} className="relative text-sm h-9 min-w-[180px]"
transition={{ duration: 0.5 }}
className="space-y-8"
>
{/* Header */}
<div className="text-center space-y-4">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", delay: 0.2 }}
className="relative inline-block"
>
<Logo className="w-20 h-20 mx-auto rounded-full" />
</motion.div>
<div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tight">Configure Your AI</h1>
<p className="text-muted-foreground text-lg">
Add your LLM provider to get started with SurfSense
</p>
</div>
</div>
{/* Config Form */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
> >
<Card className="border-2 border-muted shadow-xl overflow-hidden"> <span className={isSubmitting ? "opacity-0" : ""}>Start Using SurfSense</span>
<CardHeader className="pb-4"> {isSubmitting && <Spinner size="sm" className="absolute" />}
<CardTitle className="text-xl">LLM Configuration</CardTitle> </Button>
</CardHeader> <p className="text-xs text-muted-foreground">You can add more configurations later</p>
<CardContent> </div>
<LLMConfigForm
searchSpaceId={searchSpaceId}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
mode="create"
showAdvanced={true}
submitLabel="Start Using SurfSense"
initialData={{
citations_enabled: true,
use_default_system_instructions: true,
}}
/>
</CardContent>
</Card>
</motion.div>
{/* Footer note */}
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
className="text-center text-sm text-muted-foreground"
>
You can add more configurations and customize settings anytime in{" "}
<button
type="button"
onClick={() => setSearchSpaceSettingsDialog({ open: true, initialTab: "general" })}
className="text-violet-500 hover:underline"
>
Settings
</button>
</motion.p>
</motion.div>
</div> </div>
</div> </div>
); );

View file

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

View file

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

View file

@ -2,6 +2,8 @@ import { atomWithMutation } from "jotai-tanstack-query";
import { toast } from "sonner"; import { toast } from "sonner";
import type { import type {
CreateImageGenConfigRequest, CreateImageGenConfigRequest,
CreateImageGenConfigResponse,
DeleteImageGenConfigResponse,
GetImageGenConfigsResponse, GetImageGenConfigsResponse,
UpdateImageGenConfigRequest, UpdateImageGenConfigRequest,
UpdateImageGenConfigResponse, UpdateImageGenConfigResponse,
@ -23,14 +25,14 @@ export const createImageGenConfigMutationAtom = atomWithMutation((get) => {
mutationFn: async (request: CreateImageGenConfigRequest) => { mutationFn: async (request: CreateImageGenConfigRequest) => {
return imageGenConfigApiService.createConfig(request); return imageGenConfigApiService.createConfig(request);
}, },
onSuccess: () => { onSuccess: (_: CreateImageGenConfigResponse, request: CreateImageGenConfigRequest) => {
toast.success("Image model configuration created"); toast.success(`${request.name} created`);
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: cacheKeys.imageGenConfigs.all(Number(searchSpaceId)), queryKey: cacheKeys.imageGenConfigs.all(Number(searchSpaceId)),
}); });
}, },
onError: (error: Error) => { onError: (error: Error) => {
toast.error(error.message || "Failed to create image model configuration"); toast.error(error.message || "Failed to create image model");
}, },
}; };
}); });
@ -48,7 +50,7 @@ export const updateImageGenConfigMutationAtom = atomWithMutation((get) => {
return imageGenConfigApiService.updateConfig(request); return imageGenConfigApiService.updateConfig(request);
}, },
onSuccess: (_: UpdateImageGenConfigResponse, request: UpdateImageGenConfigRequest) => { onSuccess: (_: UpdateImageGenConfigResponse, request: UpdateImageGenConfigRequest) => {
toast.success("Image model configuration updated"); toast.success(`${request.data.name ?? "Configuration"} updated`);
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: cacheKeys.imageGenConfigs.all(Number(searchSpaceId)), queryKey: cacheKeys.imageGenConfigs.all(Number(searchSpaceId)),
}); });
@ -57,7 +59,7 @@ export const updateImageGenConfigMutationAtom = atomWithMutation((get) => {
}); });
}, },
onError: (error: Error) => { onError: (error: Error) => {
toast.error(error.message || "Failed to update image model configuration"); toast.error(error.message || "Failed to update image model");
}, },
}; };
}); });
@ -71,21 +73,21 @@ export const deleteImageGenConfigMutationAtom = atomWithMutation((get) => {
return { return {
mutationKey: ["image-gen-configs", "delete"], mutationKey: ["image-gen-configs", "delete"],
enabled: !!searchSpaceId, enabled: !!searchSpaceId,
mutationFn: async (id: number) => { mutationFn: async (request: { id: number; name: string }) => {
return imageGenConfigApiService.deleteConfig(id); return imageGenConfigApiService.deleteConfig(request.id);
}, },
onSuccess: (_, id: number) => { onSuccess: (_: DeleteImageGenConfigResponse, request: { id: number; name: string }) => {
toast.success("Image model configuration deleted"); toast.success(`${request.name} deleted`);
queryClient.setQueryData( queryClient.setQueryData(
cacheKeys.imageGenConfigs.all(Number(searchSpaceId)), cacheKeys.imageGenConfigs.all(Number(searchSpaceId)),
(oldData: GetImageGenConfigsResponse | undefined) => { (oldData: GetImageGenConfigsResponse | undefined) => {
if (!oldData) return oldData; if (!oldData) return oldData;
return oldData.filter((config) => config.id !== id); return oldData.filter((config) => config.id !== request.id);
} }
); );
}, },
onError: (error: Error) => { onError: (error: Error) => {
toast.error(error.message || "Failed to delete image model configuration"); toast.error(error.message || "Failed to delete image model");
}, },
}; };
}); });

View file

@ -2,7 +2,9 @@ import { atomWithMutation } from "jotai-tanstack-query";
import { toast } from "sonner"; import { toast } from "sonner";
import type { import type {
CreateNewLLMConfigRequest, CreateNewLLMConfigRequest,
CreateNewLLMConfigResponse,
DeleteNewLLMConfigRequest, DeleteNewLLMConfigRequest,
DeleteNewLLMConfigResponse,
GetNewLLMConfigsResponse, GetNewLLMConfigsResponse,
UpdateLLMPreferencesRequest, UpdateLLMPreferencesRequest,
UpdateNewLLMConfigRequest, UpdateNewLLMConfigRequest,
@ -25,14 +27,14 @@ export const createNewLLMConfigMutationAtom = atomWithMutation((get) => {
mutationFn: async (request: CreateNewLLMConfigRequest) => { mutationFn: async (request: CreateNewLLMConfigRequest) => {
return newLLMConfigApiService.createConfig(request); return newLLMConfigApiService.createConfig(request);
}, },
onSuccess: () => { onSuccess: (_: CreateNewLLMConfigResponse, request: CreateNewLLMConfigRequest) => {
toast.success("Configuration created successfully"); toast.success(`${request.name} created`);
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: cacheKeys.newLLMConfigs.all(Number(searchSpaceId)), queryKey: cacheKeys.newLLMConfigs.all(Number(searchSpaceId)),
}); });
}, },
onError: (error: Error) => { onError: (error: Error) => {
toast.error(error.message || "Failed to create configuration"); toast.error(error.message || "Failed to create LLM model");
}, },
}; };
}); });
@ -50,7 +52,7 @@ export const updateNewLLMConfigMutationAtom = atomWithMutation((get) => {
return newLLMConfigApiService.updateConfig(request); return newLLMConfigApiService.updateConfig(request);
}, },
onSuccess: (_: UpdateNewLLMConfigResponse, request: UpdateNewLLMConfigRequest) => { onSuccess: (_: UpdateNewLLMConfigResponse, request: UpdateNewLLMConfigRequest) => {
toast.success("Configuration updated successfully"); toast.success(`${request.data.name ?? "Configuration"} updated`);
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: cacheKeys.newLLMConfigs.all(Number(searchSpaceId)), queryKey: cacheKeys.newLLMConfigs.all(Number(searchSpaceId)),
}); });
@ -59,7 +61,7 @@ export const updateNewLLMConfigMutationAtom = atomWithMutation((get) => {
}); });
}, },
onError: (error: Error) => { onError: (error: Error) => {
toast.error(error.message || "Failed to update configuration"); toast.error(error.message || "Failed to update");
}, },
}; };
}); });
@ -73,11 +75,14 @@ export const deleteNewLLMConfigMutationAtom = atomWithMutation((get) => {
return { return {
mutationKey: ["new-llm-configs", "delete"], mutationKey: ["new-llm-configs", "delete"],
enabled: !!searchSpaceId, enabled: !!searchSpaceId,
mutationFn: async (request: DeleteNewLLMConfigRequest) => { mutationFn: async (request: DeleteNewLLMConfigRequest & { name: string }) => {
return newLLMConfigApiService.deleteConfig(request); return newLLMConfigApiService.deleteConfig({ id: request.id });
}, },
onSuccess: (_, request: DeleteNewLLMConfigRequest) => { onSuccess: (
toast.success("Configuration deleted successfully"); _: DeleteNewLLMConfigResponse,
request: DeleteNewLLMConfigRequest & { name: string }
) => {
toast.success(`${request.name} deleted`);
queryClient.setQueryData( queryClient.setQueryData(
cacheKeys.newLLMConfigs.all(Number(searchSpaceId)), cacheKeys.newLLMConfigs.all(Number(searchSpaceId)),
(oldData: GetNewLLMConfigsResponse | undefined) => { (oldData: GetNewLLMConfigsResponse | undefined) => {
@ -87,7 +92,7 @@ export const deleteNewLLMConfigMutationAtom = atomWithMutation((get) => {
); );
}, },
onError: (error: Error) => { onError: (error: Error) => {
toast.error(error.message || "Failed to delete configuration"); toast.error(error.message || "Failed to delete");
}, },
}; };
}); });

View file

@ -33,6 +33,9 @@ const initialState: TabsState = {
activeTabId: "chat-new", activeTabId: "chat-new",
}; };
// Prevent race conditions where route-sync recreates a just-deleted chat tab.
const deletedChatIdsAtom = atom<Set<number>>(new Set<number>());
const sessionStorageAdapter = createJSONStorage<TabsState>( const sessionStorageAdapter = createJSONStorage<TabsState>(
() => (typeof window !== "undefined" ? sessionStorage : undefined) as Storage () => (typeof window !== "undefined" ? sessionStorage : undefined) as Storage
); );
@ -71,6 +74,10 @@ export const syncChatTabAtom = atom(
set, set,
{ chatId, title, chatUrl }: { chatId: number | null; title?: string; chatUrl?: string } { chatId, title, chatUrl }: { chatId: number | null; title?: string; chatUrl?: string }
) => { ) => {
if (chatId && get(deletedChatIdsAtom).has(chatId)) {
return;
}
const state = get(tabsStateAtom); const state = get(tabsStateAtom);
const tabId = makeChatTabId(chatId); const tabId = makeChatTabId(chatId);
const existing = state.tabs.find((t) => t.id === tabId); const existing = state.tabs.find((t) => t.id === tabId);
@ -128,6 +135,19 @@ export const updateChatTabTitleAtom = atom(
(get, set, { chatId, title }: { chatId: number; title: string }) => { (get, set, { chatId, title }: { chatId: number; title: string }) => {
const state = get(tabsStateAtom); const state = get(tabsStateAtom);
const tabId = makeChatTabId(chatId); const tabId = makeChatTabId(chatId);
const hasExactTab = state.tabs.some((t) => t.id === tabId);
// During lazy thread creation, title updates can arrive before "chat-new"
// is swapped to chat-{id}. In that case, promote the active "chat-new" tab.
if (!hasExactTab && state.activeTabId === "chat-new") {
set(tabsStateAtom, {
...state,
activeTabId: tabId,
tabs: state.tabs.map((t) => (t.id === "chat-new" ? { ...t, id: tabId, chatId, title } : t)),
});
return;
}
set(tabsStateAtom, { set(tabsStateAtom, {
...state, ...state,
tabs: state.tabs.map((t) => (t.id === tabId ? { ...t, title } : t)), tabs: state.tabs.map((t) => (t.id === tabId ? { ...t, title } : t)),
@ -213,7 +233,39 @@ export const closeTabAtom = atom(null, (get, set, tabId: string) => {
return remaining.find((t) => t.id === newActiveId) ?? null; return remaining.find((t) => t.id === newActiveId) ?? null;
}); });
/** Remove a chat tab by chat ID (used when a chat is deleted). */
export const removeChatTabAtom = atom(null, (get, set, chatId: number) => {
const state = get(tabsStateAtom);
const tabId = makeChatTabId(chatId);
const idx = state.tabs.findIndex((t) => t.id === tabId);
if (idx === -1) return null;
const deletedChatIds = get(deletedChatIdsAtom);
set(deletedChatIdsAtom, new Set([...deletedChatIds, chatId]));
const remaining = state.tabs.filter((t) => t.id !== tabId);
// Always keep at least one tab available.
if (remaining.length === 0) {
set(tabsStateAtom, {
tabs: [INITIAL_CHAT_TAB],
activeTabId: "chat-new",
});
return INITIAL_CHAT_TAB;
}
let newActiveId = state.activeTabId;
if (state.activeTabId === tabId) {
const newIdx = Math.min(idx, remaining.length - 1);
newActiveId = remaining[newIdx].id;
}
set(tabsStateAtom, { tabs: remaining, activeTabId: newActiveId });
return remaining.find((t) => t.id === newActiveId) ?? null;
});
/** Reset tabs when switching search spaces. */ /** Reset tabs when switching search spaces. */
export const resetTabsAtom = atom(null, (_get, set) => { export const resetTabsAtom = atom(null, (_get, set) => {
set(tabsStateAtom, { ...initialState }); set(tabsStateAtom, { ...initialState });
set(deletedChatIdsAtom, new Set<number>());
}); });

View file

@ -7,16 +7,30 @@ import {
useAuiState, useAuiState,
} from "@assistant-ui/react"; } from "@assistant-ui/react";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { CheckIcon, ClipboardPaste, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react"; import {
CheckIcon,
ClipboardPaste,
CopyIcon,
DownloadIcon,
ExternalLink,
Globe,
MessageSquare,
RefreshCwIcon,
} from "lucide-react";
import type { FC } from "react"; import type { FC } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { commentsEnabledAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom"; import { commentsEnabledAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import {
CitationMetadataProvider,
useAllCitationMetadata,
} from "@/components/assistant-ui/citation-metadata-context";
import { MarkdownText } from "@/components/assistant-ui/markdown-text"; import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container"; import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container";
import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet"; import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet";
import type { SerializableCitation } from "@/components/tool-ui/citation";
import { import {
CreateConfluencePageToolUI, CreateConfluencePageToolUI,
DeleteConfluencePageToolUI, DeleteConfluencePageToolUI,
@ -40,10 +54,6 @@ import {
CreateGoogleDriveFileToolUI, CreateGoogleDriveFileToolUI,
DeleteGoogleDriveFileToolUI, DeleteGoogleDriveFileToolUI,
} from "@/components/tool-ui/google-drive"; } from "@/components/tool-ui/google-drive";
import {
CreateOneDriveFileToolUI,
DeleteOneDriveFileToolUI,
} from "@/components/tool-ui/onedrive";
import { import {
CreateJiraIssueToolUI, CreateJiraIssueToolUI,
DeleteJiraIssueToolUI, DeleteJiraIssueToolUI,
@ -59,13 +69,159 @@ import {
DeleteNotionPageToolUI, DeleteNotionPageToolUI,
UpdateNotionPageToolUI, UpdateNotionPageToolUI,
} from "@/components/tool-ui/notion"; } from "@/components/tool-ui/notion";
import { CreateOneDriveFileToolUI, DeleteOneDriveFileToolUI } from "@/components/tool-ui/onedrive";
import { SandboxExecuteToolUI } from "@/components/tool-ui/sandbox-execute"; import { SandboxExecuteToolUI } from "@/components/tool-ui/sandbox-execute";
import {
openSafeNavigationHref,
resolveSafeNavigationHref,
} from "@/components/tool-ui/shared/media";
import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory"; import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory";
import { GenerateVideoPresentationToolUI } from "@/components/tool-ui/video-presentation"; import { GenerateVideoPresentationToolUI } from "@/components/tool-ui/video-presentation";
import { Drawer, DrawerContent, DrawerHandle, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
import { useComments } from "@/hooks/use-comments"; import { useComments } from "@/hooks/use-comments";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function extractDomain(url: string): string | undefined {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return undefined;
}
}
function useCitationsFromMetadata(): SerializableCitation[] {
const allCitations = useAllCitationMetadata();
return useMemo(() => {
const result: SerializableCitation[] = [];
for (const [url, meta] of allCitations) {
const domain = extractDomain(url);
result.push({
id: `url-cite-${url}`,
href: url,
title: meta.title,
snippet: meta.snippet,
domain,
favicon: domain ? `https://www.google.com/s2/favicons?domain=${domain}&sz=32` : undefined,
type: "webpage",
});
}
return result;
}, [allCitations]);
}
const MobileCitationDrawer: FC = () => {
const [open, setOpen] = useState(false);
const citations = useCitationsFromMetadata();
if (citations.length === 0) return null;
const maxIcons = 4;
const visible = citations.slice(0, maxIcons);
const remainingCount = Math.max(0, citations.length - maxIcons);
const handleNavigate = (citation: SerializableCitation) => {
const href = resolveSafeNavigationHref(citation.href);
if (href) openSafeNavigationHref(href);
};
return (
<>
<button
type="button"
onClick={() => 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"
)}
>
<div className="flex items-center">
{visible.map((citation, index) => (
<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
<img
src={citation.favicon}
alt=""
aria-hidden="true"
width={18}
height={18}
className="size-4.5 rounded-full object-cover"
/>
) : (
<Globe 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>
<Drawer open={open} onOpenChange={setOpen}>
<DrawerContent className="max-h-[85vh] flex flex-col">
<DrawerHandle />
<DrawerHeader className="text-left">
<DrawerTitle className="text-base font-semibold">Sources</DrawerTitle>
</DrawerHeader>
<div className="overflow-y-auto flex-1 min-h-0 px-1 pb-6">
{citations.map((citation) => (
<button
key={citation.id}
type="button"
onClick={() => handleNavigate(citation)}
className="group flex w-full items-center gap-2.5 rounded-md px-3 py-2.5 text-left transition-colors hover:bg-muted focus-visible:bg-muted focus-visible:outline-none"
>
{citation.favicon ? (
// biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain
<img
src={citation.favicon}
alt=""
aria-hidden="true"
width={16}
height={16}
className="bg-muted size-4 shrink-0 rounded object-cover"
/>
) : (
<Globe className="text-muted-foreground size-4 shrink-0" aria-hidden="true" />
)}
<div className="min-w-0 flex-1">
<p className="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 size-3.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" />
</button>
))}
</div>
</DrawerContent>
</Drawer>
</>
);
};
export const MessageError: FC = () => { export const MessageError: FC = () => {
return ( return (
<MessagePrimitive.Error> <MessagePrimitive.Error>
@ -77,8 +233,10 @@ export const MessageError: FC = () => {
}; };
const AssistantMessageInner: FC = () => { const AssistantMessageInner: FC = () => {
const isMobile = !useMediaQuery("(min-width: 768px)");
return ( return (
<> <CitationMetadataProvider>
<div className="aui-assistant-message-content wrap-break-word px-2 text-foreground leading-relaxed"> <div className="aui-assistant-message-content wrap-break-word px-2 text-foreground leading-relaxed">
<MessagePrimitive.Parts <MessagePrimitive.Parts
components={{ components={{
@ -116,6 +274,7 @@ const AssistantMessageInner: FC = () => {
create_confluence_page: CreateConfluencePageToolUI, create_confluence_page: CreateConfluencePageToolUI,
update_confluence_page: UpdateConfluencePageToolUI, update_confluence_page: UpdateConfluencePageToolUI,
delete_confluence_page: DeleteConfluencePageToolUI, delete_confluence_page: DeleteConfluencePageToolUI,
web_search: () => null,
link_preview: () => null, link_preview: () => null,
multi_link_preview: () => null, multi_link_preview: () => null,
scrape_webpage: () => null, scrape_webpage: () => null,
@ -127,10 +286,16 @@ const AssistantMessageInner: FC = () => {
<MessageError /> <MessageError />
</div> </div>
{isMobile && (
<div className="ml-2 mt-2">
<MobileCitationDrawer />
</div>
)}
<div className="aui-assistant-message-footer mt-1 mb-5 ml-2 flex"> <div className="aui-assistant-message-footer mt-1 mb-5 ml-2 flex">
<AssistantActionBar /> <AssistantActionBar />
</div> </div>
</> </CitationMetadataProvider>
); );
}; };

View file

@ -0,0 +1,69 @@
"use client";
import { useAuiState } from "@assistant-ui/react";
import { createContext, type FC, type ReactNode, useContext, useMemo } from "react";
export interface CitationMeta {
title: string;
snippet?: string;
}
type CitationMetadataMap = ReadonlyMap<string, CitationMeta>;
const CitationMetadataContext = createContext<CitationMetadataMap>(new Map());
interface ToolCallResult {
status?: string;
citations?: Record<string, { title: string; snippet?: string }>;
}
interface MessageContent {
type: string;
toolName?: string;
result?: unknown;
}
export const CitationMetadataProvider: FC<{ children: ReactNode }> = ({ children }) => {
const content = useAuiState(
({ message }) => (message as { content?: MessageContent[] })?.content
);
const metadataMap = useMemo<CitationMetadataMap>(() => {
if (!content || !Array.isArray(content)) return new Map();
const merged = new Map<string, CitationMeta>();
for (const part of content) {
if (part.type !== "tool-call" || part.toolName !== "web_search" || !part.result) {
continue;
}
const result = part.result as ToolCallResult;
const citations = result.citations;
if (!citations || typeof citations !== "object") continue;
for (const [url, meta] of Object.entries(citations)) {
if (url.startsWith("http") && meta.title && !merged.has(url)) {
merged.set(url, { title: meta.title, snippet: meta.snippet });
}
}
}
return merged;
}, [content]);
return (
<CitationMetadataContext.Provider value={metadataMap}>
{children}
</CitationMetadataContext.Provider>
);
};
export function useCitationMetadata(url: string): CitationMeta | undefined {
const map = useContext(CitationMetadataContext);
return map.get(url);
}
export function useAllCitationMetadata(): CitationMetadataMap {
return useContext(CitationMetadataContext);
}

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { useAtomValue, useSetAtom } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { AlertTriangle, Cable, Settings } from "lucide-react"; import { AlertTriangle, Settings } from "lucide-react";
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react"; import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom"; import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
@ -12,17 +12,14 @@ import {
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms"; import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsContent } from "@/components/ui/tabs"; import { Tabs, TabsContent } from "@/components/ui/tabs";
import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { useConnectorsSync } from "@/hooks/use-connectors-sync"; import { useConnectorsSync } from "@/hooks/use-connectors-sync";
import { PICKER_CLOSE_EVENT, PICKER_OPEN_EVENT } from "@/hooks/use-google-picker"; import { PICKER_CLOSE_EVENT, PICKER_OPEN_EVENT } from "@/hooks/use-google-picker";
import { useZeroDocumentTypeCounts } from "@/hooks/use-zero-document-type-counts"; import { useZeroDocumentTypeCounts } from "@/hooks/use-zero-document-type-counts";
import { cn } from "@/lib/utils";
import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header"; import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header";
import { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-view"; import { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-view";
import { ConnectorEditView } from "./connector-popup/connector-configs/views/connector-edit-view"; import { ConnectorEditView } from "./connector-popup/connector-configs/views/connector-edit-view";
@ -47,7 +44,7 @@ interface ConnectorIndicatorProps {
} }
export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, ConnectorIndicatorProps>( export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, ConnectorIndicatorProps>(
({ showTrigger = true }, ref) => { (_props, ref) => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom); const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
useAtomValue(currentUserAtom); useAtomValue(currentUserAtom);
@ -74,8 +71,6 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
// Real-time document type counts via Zero (updates instantly as docs are indexed) // Real-time document type counts via Zero (updates instantly as docs are indexed)
const documentTypeCounts = useZeroDocumentTypeCounts(searchSpaceId); const documentTypeCounts = useZeroDocumentTypeCounts(searchSpaceId);
const documentTypesLoading = documentTypeCounts === undefined;
// Read status inbox items from shared atom (populated by LayoutDataProvider) // Read status inbox items from shared atom (populated by LayoutDataProvider)
// instead of creating a duplicate useInbox("status") hook. // instead of creating a duplicate useInbox("status") hook.
const statusInboxItems = useAtomValue(statusInboxItemsAtom); const statusInboxItems = useAtomValue(statusInboxItemsAtom);
@ -178,8 +173,6 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
inboxItems inboxItems
); );
const isLoading = connectorsLoading || documentTypesLoading;
// Get document types that have documents in the search space // Get document types that have documents in the search space
const activeDocumentTypes = documentTypeCounts const activeDocumentTypes = documentTypeCounts
? Object.entries(documentTypeCounts).filter(([, count]) => count > 0) ? Object.entries(documentTypeCounts).filter(([, count]) => count > 0)
@ -205,41 +198,6 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
return ( return (
<Dialog open={isOpen} modal={false} onOpenChange={handleOpenChange}> <Dialog open={isOpen} modal={false} onOpenChange={handleOpenChange}>
{showTrigger && (
<TooltipIconButton
data-joyride="connector-icon"
tooltip={
hasConnectors ? `Manage ${activeConnectorsCount} connectors` : "Connect your data"
}
side="bottom"
className={cn(
"size-[34px] rounded-full p-1 flex items-center justify-center transition-colors relative",
"hover:bg-muted-foreground/15 dark:hover:bg-muted-foreground/30",
"outline-none focus:outline-none focus-visible:outline-none font-semibold text-xs",
"border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none"
)}
aria-label={
hasConnectors
? `View ${activeConnectorsCount} connectors`
: "Add your first connector"
}
onClick={() => handleOpenChange(true)}
>
{isLoading ? (
<Spinner size="sm" />
) : (
<>
<Cable className="size-4 stroke-[1.5px]" />
{activeConnectorsCount > 0 && (
<span className="absolute -top-0.5 right-0 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-medium rounded-full bg-primary text-primary-foreground shadow-sm select-none">
{activeConnectorsCount > 99 ? "99+" : activeConnectorsCount}
</span>
)}
</>
)}
</TooltipIconButton>
)}
{isOpen && {isOpen &&
createPortal( createPortal(
<div <div
@ -340,11 +298,11 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
onBack={handleBackFromEdit} onBack={handleBackFromEdit}
onQuickIndex={(() => { onQuickIndex={(() => {
const cfg = connectorConfig || editingConnector.config; const cfg = connectorConfig || editingConnector.config;
const isDriveOrOneDrive = const isDriveOrOneDrive =
editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" || editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" || editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
editingConnector.connector_type === "ONEDRIVE_CONNECTOR"; editingConnector.connector_type === "ONEDRIVE_CONNECTOR";
const hasDriveItems = isDriveOrOneDrive const hasDriveItems = isDriveOrOneDrive
? ((cfg?.selected_folders as unknown[]) ?? []).length > 0 || ? ((cfg?.selected_folders as unknown[]) ?? []).length > 0 ||
((cfg?.selected_files as unknown[]) ?? []).length > 0 ((cfg?.selected_files as unknown[]) ?? []).length > 0
: true; : true;

View file

@ -143,7 +143,7 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
size="sm" size="sm"
variant={isConnected ? "secondary" : "default"} variant={isConnected ? "secondary" : "default"}
className={cn( className={cn(
"h-8 text-[11px] px-3 rounded-lg shrink-0 font-medium", "relative h-8 text-[11px] px-3 rounded-lg shrink-0 font-medium items-center justify-center",
isConnected && isConnected &&
"bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80", "bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80",
!isConnected && "shadow-xs" !isConnected && "shadow-xs"
@ -151,19 +151,18 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
onClick={isConnected ? onManage : onConnect} onClick={isConnected ? onManage : onConnect}
disabled={isConnecting || !isEnabled} disabled={isConnecting || !isEnabled}
> >
{isConnecting ? ( <span className={isConnecting ? "opacity-0" : ""}>
<Spinner size="xs" /> {!isEnabled
) : !isEnabled ? ( ? "Unavailable"
"Unavailable" : isConnected
) : isConnected ? ( ? "Manage"
"Manage" : id === "youtube-crawler"
) : id === "youtube-crawler" ? ( ? "Add"
"Add" : connectorType
) : connectorType ? ( ? "Connect"
"Connect" : "Add"}
) : ( </span>
"Add" {isConnecting && <Spinner size="xs" className="absolute" />}
)}
</Button> </Button>
</div> </div>
); );

View file

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

View file

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

View file

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

View file

@ -1,9 +1,10 @@
"use client"; "use client";
import { ExternalLink } from "lucide-react";
import type { FC } from "react"; import type { FC } from "react";
import { useState } from "react"; import { useState } from "react";
import { useCitationMetadata } from "@/components/assistant-ui/citation-metadata-context";
import { SourceDetailPanel } from "@/components/new-chat/source-detail-panel"; import { SourceDetailPanel } from "@/components/new-chat/source-detail-panel";
import { Citation } from "@/components/tool-ui/citation";
interface InlineCitationProps { interface InlineCitationProps {
chunkId: number; chunkId: number;
@ -55,21 +56,23 @@ interface UrlCitationProps {
/** /**
* Inline citation for live web search results (URL-based chunk IDs). * Inline citation for live web search results (URL-based chunk IDs).
* Renders a clickable badge showing the source domain that opens the URL in a new tab. * Renders a compact chip with favicon + domain and a hover popover showing the
* page title and snippet (extracted deterministically from web_search tool results).
*/ */
export const UrlCitation: FC<UrlCitationProps> = ({ url }) => { export const UrlCitation: FC<UrlCitationProps> = ({ url }) => {
const domain = extractDomain(url); const domain = extractDomain(url);
const meta = useCitationMetadata(url);
return ( return (
<a <Citation
id={`url-cite-${url}`}
href={url} href={url}
target="_blank" title={meta?.title || domain}
rel="noopener noreferrer" snippet={meta?.snippet}
className="text-[10px] font-bold bg-primary/80 hover:bg-primary text-primary-foreground rounded-full h-4 px-1.5 inline-flex items-center gap-0.5 align-super cursor-pointer transition-colors ml-0.5 no-underline" domain={domain}
title={url} favicon={`https://www.google.com/s2/favicons?domain=${domain}&sz=32`}
> variant="inline"
<ExternalLink className="size-2.5 shrink-0" /> type="webpage"
{domain} />
</a>
); );
}; };

View file

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

View file

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

View file

@ -20,8 +20,6 @@ export const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?:
steps, steps,
isThreadRunning = true, isThreadRunning = true,
}) => { }) => {
const [isOpen, setIsOpen] = useState(true);
const getEffectiveStatus = useCallback( const getEffectiveStatus = useCallback(
(step: ThinkingStep): "pending" | "in_progress" | "completed" => { (step: ThinkingStep): "pending" | "in_progress" | "completed" => {
if (step.status === "in_progress" && !isThreadRunning) { if (step.status === "in_progress" && !isThreadRunning) {
@ -38,12 +36,18 @@ export const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?:
!isThreadRunning && !isThreadRunning &&
steps.every((s) => getEffectiveStatus(s) === "completed"); steps.every((s) => getEffectiveStatus(s) === "completed");
const isProcessing = isThreadRunning && !allCompleted; const isProcessing = isThreadRunning && !allCompleted;
const [isOpen, setIsOpen] = useState(() => isProcessing);
useEffect(() => { useEffect(() => {
if (isProcessing) {
setIsOpen(true);
return;
}
if (allCompleted) { if (allCompleted) {
setIsOpen(false); setIsOpen(false);
} }
}, [allCompleted]); }, [allCompleted, isProcessing]);
if (steps.length === 0) return null; if (steps.length === 0) return null;

View file

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

View file

@ -3,6 +3,7 @@ import { useAtomValue } from "jotai";
import { CheckIcon, CopyIcon, FileText, Pen } from "lucide-react"; import { CheckIcon, CopyIcon, FileText, Pen } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import { type FC, useState } from "react"; import { type FC, useState } from "react";
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom"; import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
@ -51,6 +52,8 @@ export const UserMessage: FC = () => {
const mentionedDocs = messageId ? messageDocumentsMap[messageId] : undefined; const mentionedDocs = messageId ? messageDocumentsMap[messageId] : undefined;
const metadata = useAuiState(({ message }) => message?.metadata); const metadata = useAuiState(({ message }) => message?.metadata);
const author = metadata?.custom?.author as AuthorMetadata | undefined; const author = metadata?.custom?.author as AuthorMetadata | undefined;
const isSharedChat = useAtomValue(currentThreadAtom).visibility === "SEARCH_SPACE";
const showAvatar = isSharedChat && !!author;
return ( return (
<MessagePrimitive.Root <MessagePrimitive.Root
@ -81,7 +84,7 @@ export const UserMessage: FC = () => {
<UserActionBar /> <UserActionBar />
</div> </div>
</div> </div>
{author && ( {showAvatar && (
<div className="shrink-0 mb-1.5"> <div className="shrink-0 mb-1.5">
<UserAvatar displayName={author.displayName} avatarUrl={author.avatarUrl} /> <UserAvatar displayName={author.displayName} avatarUrl={author.avatarUrl} />
</div> </div>

View file

@ -40,6 +40,8 @@ import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { DND_TYPES } from "./FolderNode"; import { DND_TYPES } from "./FolderNode";
const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]);
export interface DocumentNodeDoc { export interface DocumentNodeDoc {
id: number; id: number;
title: string; title: string;
@ -78,7 +80,9 @@ export const DocumentNode = React.memo(function DocumentNode({
const statusState = doc.status?.state ?? "ready"; const statusState = doc.status?.state ?? "ready";
const isSelectable = statusState !== "pending" && statusState !== "processing"; const isSelectable = statusState !== "pending" && statusState !== "processing";
const isEditable = const isEditable =
doc.document_type === "NOTE" && statusState !== "pending" && statusState !== "processing"; EDITABLE_DOCUMENT_TYPES.has(doc.document_type) &&
statusState !== "pending" &&
statusState !== "processing";
const handleCheckChange = useCallback(() => { const handleCheckChange = useCallback(() => {
if (isSelectable) { if (isSelectable) {

View file

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

View file

@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom"; import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor"; import { PlateEditor } from "@/components/editor/plate-editor";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer"; import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
@ -18,6 +19,8 @@ interface EditorContent {
source_markdown: string; source_markdown: string;
} }
const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]);
function EditorPanelSkeleton() { function EditorPanelSkeleton() {
return ( return (
<div className="space-y-6 p-6"> <div className="space-y-6 p-6">
@ -165,12 +168,16 @@ export function EditorPanelContent({
} }
}, [documentId, searchSpaceId]); }, [documentId, searchSpaceId]);
const isEditableType = editorDoc
? EDITABLE_DOCUMENT_TYPES.has(editorDoc.document_type ?? "")
: false;
return ( return (
<> <>
<div className="flex items-center justify-between px-4 py-2 shrink-0 border-b"> <div className="flex items-center justify-between px-4 py-2 shrink-0 border-b">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold truncate">{displayTitle}</h2> <h2 className="text-sm font-semibold truncate">{displayTitle}</h2>
{editedMarkdown !== null && ( {isEditableType && editedMarkdown !== null && (
<p className="text-[10px] text-muted-foreground">Unsaved changes</p> <p className="text-[10px] text-muted-foreground">Unsaved changes</p>
)} )}
</div> </div>
@ -193,7 +200,7 @@ export function EditorPanelContent({
<p className="text-sm text-red-500 mt-1">{error || "An unknown error occurred"}</p> <p className="text-sm text-red-500 mt-1">{error || "An unknown error occurred"}</p>
</div> </div>
</div> </div>
) : ( ) : isEditableType ? (
<PlateEditor <PlateEditor
key={documentId} key={documentId}
preset="full" preset="full"
@ -208,6 +215,10 @@ export function EditorPanelContent({
defaultEditing={true} defaultEditing={true}
className="[&_[role=toolbar]]:!bg-sidebar" className="[&_[role=toolbar]]:!bg-sidebar"
/> />
) : (
<div className="h-full overflow-y-auto px-5 py-4">
<MarkdownViewer content={editorDoc.source_markdown} />
</div>
)} )}
</div> </div>
</> </>

View file

@ -20,7 +20,12 @@ import {
teamDialogAtom, teamDialogAtom,
userSettingsDialogAtom, userSettingsDialogAtom,
} from "@/atoms/settings/settings-dialog.atoms"; } from "@/atoms/settings/settings-dialog.atoms";
import { resetTabsAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom"; import {
removeChatTabAtom,
resetTabsAtom,
syncChatTabAtom,
type Tab,
} from "@/atoms/tabs/tabs.atom";
import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { MorePagesDialog } from "@/components/settings/more-pages-dialog"; import { MorePagesDialog } from "@/components/settings/more-pages-dialog";
import { SearchSpaceSettingsDialog } from "@/components/settings/search-space-settings-dialog"; import { SearchSpaceSettingsDialog } from "@/components/settings/search-space-settings-dialog";
@ -103,6 +108,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
const resetCurrentThread = useSetAtom(resetCurrentThreadAtom); const resetCurrentThread = useSetAtom(resetCurrentThreadAtom);
const syncChatTab = useSetAtom(syncChatTabAtom); const syncChatTab = useSetAtom(syncChatTabAtom);
const resetTabs = useSetAtom(resetTabsAtom); const resetTabs = useSetAtom(resetTabsAtom);
const removeChatTab = useSetAtom(removeChatTabAtom);
// State for handling new chat navigation when router is out of sync // State for handling new chat navigation when router is out of sync
const [pendingNewChat, setPendingNewChat] = useState(false); const [pendingNewChat, setPendingNewChat] = useState(false);
@ -325,7 +331,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
const thread = threadsData?.threads?.find((t) => t.id === chatId); const thread = threadsData?.threads?.find((t) => t.id === chatId);
syncChatTab({ syncChatTab({
chatId, chatId,
title: thread?.title || (chatId ? `Chat ${chatId}` : "New Chat"), // Avoid overwriting live SSE-updated tab titles with fallback values.
title: chatId ? (thread?.title ?? undefined) : "New Chat",
chatUrl, chatUrl,
}); });
}, [currentChatId, searchSpaceId, threadsData?.threads, syncChatTab]); }, [currentChatId, searchSpaceId, threadsData?.threads, syncChatTab]);
@ -637,15 +644,20 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
setIsDeletingChat(true); setIsDeletingChat(true);
try { try {
await deleteThread(chatToDelete.id); await deleteThread(chatToDelete.id);
const fallbackTab = removeChatTab(chatToDelete.id);
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
if (currentChatId === chatToDelete.id) { if (currentChatId === chatToDelete.id) {
resetCurrentThread(); resetCurrentThread();
const isOutOfSync = currentThreadState.id !== null && !params?.chat_id; if (fallbackTab?.type === "chat" && fallbackTab.chatUrl) {
if (isOutOfSync) { router.push(fallbackTab.chatUrl);
window.history.replaceState(null, "", `/dashboard/${searchSpaceId}/new-chat`);
setChatResetKey((k) => k + 1);
} else { } else {
router.push(`/dashboard/${searchSpaceId}/new-chat`); const isOutOfSync = currentThreadState.id !== null && !params?.chat_id;
if (isOutOfSync) {
window.history.replaceState(null, "", `/dashboard/${searchSpaceId}/new-chat`);
setChatResetKey((k) => k + 1);
} else {
router.push(`/dashboard/${searchSpaceId}/new-chat`);
}
} }
} }
} catch (error) { } catch (error) {
@ -664,6 +676,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
currentThreadState.id, currentThreadState.id,
params?.chat_id, params?.chat_id,
router, router,
removeChatTab,
]); ]);
// Rename handler // Rename handler
@ -795,9 +808,10 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
confirmDeleteChat(); confirmDeleteChat();
}} }}
disabled={isDeletingChat} disabled={isDeletingChat}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 gap-2" className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90 items-center justify-center"
> >
{isDeletingChat ? <Spinner size="sm" /> : tCommon("delete")} <span className={isDeletingChat ? "opacity-0" : ""}>{tCommon("delete")}</span>
{isDeletingChat && <Spinner size="sm" className="absolute" />}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@ -835,15 +849,13 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
<Button <Button
onClick={confirmRenameChat} onClick={confirmRenameChat}
disabled={isRenamingChat || !newChatTitle.trim()} disabled={isRenamingChat || !newChatTitle.trim()}
className="gap-2" className="relative"
> >
{isRenamingChat ? ( <span className={isRenamingChat ? "opacity-0" : ""}>
<> {tSidebar("rename") || "Rename"}
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" /> </span>
{tSidebar("renaming") || "Renaming"} {isRenamingChat && (
</> <span className="absolute h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
tSidebar("rename") || "Rename"
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>
@ -869,15 +881,11 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
confirmDeleteSearchSpace(); confirmDeleteSearchSpace();
}} }}
disabled={isDeletingSearchSpace} disabled={isDeletingSearchSpace}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 gap-2" className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
> >
{isDeletingSearchSpace ? ( <span className={isDeletingSearchSpace ? "opacity-0" : ""}>{tCommon("delete")}</span>
<> {isDeletingSearchSpace && (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" /> <span className="absolute h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{t("deleting")}
</>
) : (
tCommon("delete")
)} )}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
@ -903,15 +911,11 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
confirmLeaveSearchSpace(); confirmLeaveSearchSpace();
}} }}
disabled={isLeavingSearchSpace} disabled={isLeavingSearchSpace}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 gap-2" className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
> >
{isLeavingSearchSpace ? ( <span className={isLeavingSearchSpace ? "opacity-0" : ""}>{t("leave")}</span>
<> {isLeavingSearchSpace && (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" /> <span className="absolute h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{t("leaving")}
</>
) : (
t("leave")
)} )}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>

View file

@ -1,20 +1,20 @@
"use client"; "use client";
import { useAtom, useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { PanelRight } from "lucide-react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom"; import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { hitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { reportPanelAtom } from "@/atoms/chat/report-panel.atom"; import { reportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms"; import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
import { editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom"; import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { activeTabAtom } from "@/atoms/tabs/tabs.atom"; import { activeTabAtom, tabsAtom } from "@/atoms/tabs/tabs.atom";
import { ChatHeader } from "@/components/new-chat/chat-header"; import { ChatHeader } from "@/components/new-chat/chat-header";
import { ChatShareButton } from "@/components/new-chat/chat-share-button"; import { ChatShareButton } from "@/components/new-chat/chat-share-button";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useIsMobile } from "@/hooks/use-mobile"; import { useIsMobile } from "@/hooks/use-mobile";
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence"; import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
import { cn } from "@/lib/utils";
interface HeaderProps { interface HeaderProps {
mobileMenuTrigger?: React.ReactNode; mobileMenuTrigger?: React.ReactNode;
@ -25,9 +25,21 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const activeTab = useAtomValue(activeTabAtom); const activeTab = useAtomValue(activeTabAtom);
const tabs = useAtomValue(tabsAtom);
const collapsed = useAtomValue(rightPanelCollapsedAtom);
const documentsOpen = useAtomValue(documentsSidebarOpenAtom);
const reportState = useAtomValue(reportPanelAtom);
const editorState = useAtomValue(editorPanelAtom);
const hitlEditState = useAtomValue(hitlEditPanelAtom);
const isChatPage = pathname?.includes("/new-chat") ?? false; const isChatPage = pathname?.includes("/new-chat") ?? false;
const isDocumentTab = activeTab?.type === "document"; const isDocumentTab = activeTab?.type === "document";
const reportOpen = reportState.isOpen && !!reportState.reportId;
const editorOpen = editorState.isOpen && !!editorState.documentId;
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
const showExpandButton =
!isMobile && collapsed && (documentsOpen || reportOpen || editorOpen || hitlEditOpen);
const hasTabBar = tabs.length > 1;
const currentThreadState = useAtomValue(currentThreadAtom); const currentThreadState = useAtomValue(currentThreadAtom);
@ -49,15 +61,8 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
const handleVisibilityChange = (_visibility: ChatVisibility) => {}; const handleVisibilityChange = (_visibility: ChatVisibility) => {};
const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom);
const documentsOpen = useAtomValue(documentsSidebarOpenAtom);
const reportState = useAtomValue(reportPanelAtom);
const reportOpen = reportState.isOpen && !!reportState.reportId;
const hasRightPanelContent = documentsOpen || reportOpen;
const showExpandButton = !isMobile && collapsed && hasRightPanelContent;
return ( return (
<header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 bg-main-panel/95 backdrop-blur supports-backdrop-filter:bg-main-panel/60 px-4"> <header className="sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 bg-main-panel/95 backdrop-blur supports-backdrop-filter:bg-main-panel/60 px-4">
{/* Left side - Mobile menu trigger + Model selector */} {/* Left side - Mobile menu trigger + Model selector */}
<div className="flex flex-1 items-center gap-2 min-w-0"> <div className="flex flex-1 items-center gap-2 min-w-0">
{mobileMenuTrigger} {mobileMenuTrigger}
@ -67,26 +72,12 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
</div> </div>
{/* Right side - Actions */} {/* Right side - Actions */}
<div className="flex items-center gap-2"> <div
className={cn("ml-auto flex items-center gap-2", showExpandButton && !hasTabBar && "mr-10")}
>
{hasThread && ( {hasThread && (
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} /> <ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />
)} )}
{showExpandButton && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setCollapsed(false)}
className="h-8 w-8 shrink-0"
>
<PanelRight className="h-4 w-4" />
<span className="sr-only">Expand panel</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Expand panel</TooltipContent>
</Tooltip>
)}
</div> </div>
</header> </header>
); );

View file

@ -55,7 +55,7 @@ export function RightPanelExpandButton() {
if (!collapsed || !hasContent) return null; if (!collapsed || !hasContent) return null;
return ( return (
<div className="absolute top-4 right-4 z-20"> <div className="absolute top-0 right-4 z-20 flex h-12 items-center">
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button

View file

@ -3,6 +3,11 @@
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { hitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { reportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
import { editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
import { activeTabAtom, type Tab } from "@/atoms/tabs/tabs.atom"; import { activeTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import type { InboxItem } from "@/hooks/use-inbox"; import type { InboxItem } from "@/hooks/use-inbox";
@ -13,7 +18,7 @@ import { useSidebarResize } from "../../hooks/useSidebarResize";
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types"; import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
import { Header } from "../header"; import { Header } from "../header";
import { IconRail } from "../icon-rail"; import { IconRail } from "../icon-rail";
import { RightPanel } from "../right-panel/RightPanel"; import { RightPanel, RightPanelExpandButton } from "../right-panel/RightPanel";
import { import {
AllPrivateChatsSidebarContent, AllPrivateChatsSidebarContent,
AllSharedChatsSidebarContent, AllSharedChatsSidebarContent,
@ -116,11 +121,26 @@ function MainContentPanel({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const activeTab = useAtomValue(activeTabAtom); const activeTab = useAtomValue(activeTabAtom);
const rightPanelCollapsed = useAtomValue(rightPanelCollapsedAtom);
const documentsOpen = useAtomValue(documentsSidebarOpenAtom);
const reportState = useAtomValue(reportPanelAtom);
const editorState = useAtomValue(editorPanelAtom);
const hitlEditState = useAtomValue(hitlEditPanelAtom);
const isDocumentTab = activeTab?.type === "document"; const isDocumentTab = activeTab?.type === "document";
const reportOpen = reportState.isOpen && !!reportState.reportId;
const editorOpen = editorState.isOpen && !!editorState.documentId;
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
const showRightPanelExpandButton =
rightPanelCollapsed && (documentsOpen || reportOpen || editorOpen || hitlEditOpen);
return ( return (
<div className="relative flex flex-1 flex-col rounded-xl border bg-main-panel overflow-hidden min-w-0"> <div className="relative flex flex-1 flex-col rounded-xl border bg-main-panel overflow-hidden min-w-0">
<TabBar onTabSwitch={onTabSwitch} onNewChat={onNewChat} /> <RightPanelExpandButton />
<TabBar
onTabSwitch={onTabSwitch}
onNewChat={onNewChat}
className={showRightPanelExpandButton ? "pr-14" : undefined}
/>
<Header /> <Header />
{isDocumentTab && activeTab.documentId && activeTab.searchSpaceId ? ( {isDocumentTab && activeTab.documentId && activeTab.searchSpaceId ? (

View file

@ -2,6 +2,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { format } from "date-fns"; import { format } from "date-fns";
import { useSetAtom } from "jotai";
import { import {
ArchiveIcon, ArchiveIcon,
ChevronLeft, ChevronLeft,
@ -18,6 +19,7 @@ import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { removeChatTabAtom } from "@/atoms/tabs/tabs.atom";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/animated-tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/animated-tabs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -70,6 +72,7 @@ export function AllPrivateChatsSidebarContent({
const params = useParams(); const params = useParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const removeChatTab = useSetAtom(removeChatTabAtom);
const currentChatId = Array.isArray(params.chat_id) const currentChatId = Array.isArray(params.chat_id)
? Number(params.chat_id[0]) ? Number(params.chat_id[0])
@ -158,6 +161,7 @@ export function AllPrivateChatsSidebarContent({
setDeletingThreadId(threadId); setDeletingThreadId(threadId);
try { try {
await deleteThread(threadId); await deleteThread(threadId);
const fallbackTab = removeChatTab(threadId);
toast.success(t("chat_deleted") || "Chat deleted successfully"); toast.success(t("chat_deleted") || "Chat deleted successfully");
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
@ -166,6 +170,10 @@ export function AllPrivateChatsSidebarContent({
if (currentChatId === threadId) { if (currentChatId === threadId) {
onOpenChange(false); onOpenChange(false);
setTimeout(() => { setTimeout(() => {
if (fallbackTab?.type === "chat" && fallbackTab.chatUrl) {
router.push(fallbackTab.chatUrl);
return;
}
router.push(`/dashboard/${searchSpaceId}/new-chat`); router.push(`/dashboard/${searchSpaceId}/new-chat`);
}, 250); }, 250);
} }
@ -176,7 +184,7 @@ export function AllPrivateChatsSidebarContent({
setDeletingThreadId(null); setDeletingThreadId(null);
} }
}, },
[queryClient, searchSpaceId, t, currentChatId, router, onOpenChange] [queryClient, searchSpaceId, t, currentChatId, router, onOpenChange, removeChatTab]
); );
const handleToggleArchive = useCallback( const handleToggleArchive = useCallback(

View file

@ -2,6 +2,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { format } from "date-fns"; import { format } from "date-fns";
import { useSetAtom } from "jotai";
import { import {
ArchiveIcon, ArchiveIcon,
ChevronLeft, ChevronLeft,
@ -18,6 +19,7 @@ import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { removeChatTabAtom } from "@/atoms/tabs/tabs.atom";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/animated-tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/animated-tabs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -70,6 +72,7 @@ export function AllSharedChatsSidebarContent({
const params = useParams(); const params = useParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const removeChatTab = useSetAtom(removeChatTabAtom);
const currentChatId = Array.isArray(params.chat_id) const currentChatId = Array.isArray(params.chat_id)
? Number(params.chat_id[0]) ? Number(params.chat_id[0])
@ -158,6 +161,7 @@ export function AllSharedChatsSidebarContent({
setDeletingThreadId(threadId); setDeletingThreadId(threadId);
try { try {
await deleteThread(threadId); await deleteThread(threadId);
const fallbackTab = removeChatTab(threadId);
toast.success(t("chat_deleted") || "Chat deleted successfully"); toast.success(t("chat_deleted") || "Chat deleted successfully");
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
@ -166,6 +170,10 @@ export function AllSharedChatsSidebarContent({
if (currentChatId === threadId) { if (currentChatId === threadId) {
onOpenChange(false); onOpenChange(false);
setTimeout(() => { setTimeout(() => {
if (fallbackTab?.type === "chat" && fallbackTab.chatUrl) {
router.push(fallbackTab.chatUrl);
return;
}
router.push(`/dashboard/${searchSpaceId}/new-chat`); router.push(`/dashboard/${searchSpaceId}/new-chat`);
}, 250); }, 250);
} }
@ -176,7 +184,7 @@ export function AllSharedChatsSidebarContent({
setDeletingThreadId(null); setDeletingThreadId(null);
} }
}, },
[queryClient, searchSpaceId, t, currentChatId, router, onOpenChange] [queryClient, searchSpaceId, t, currentChatId, router, onOpenChange, removeChatTab]
); );
const handleToggleArchive = useCallback( const handleToggleArchive = useCallback(

View file

@ -7,16 +7,15 @@ import { useParams } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { DocumentsFilters } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters"; import { DocumentsFilters } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters";
import { sidebarSelectedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom"; import { sidebarSelectedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms"; import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms"; import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms"; import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
import { expandedFolderIdsAtom } from "@/atoms/documents/folder.atoms"; import { expandedFolderIdsAtom } from "@/atoms/documents/folder.atoms";
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
import { agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms"; import { agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms";
import { openDocumentTabAtom } from "@/atoms/tabs/tabs.atom"; import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog"; import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog";
import type { DocumentNodeDoc } from "@/components/documents/DocumentNode"; import type { DocumentNodeDoc } from "@/components/documents/DocumentNode";
import type { FolderDisplay } from "@/components/documents/FolderNode"; import type { FolderDisplay } from "@/components/documents/FolderNode";
@ -35,22 +34,13 @@ import {
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerContent,
DrawerHandle,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { DocumentTypeEnum } from "@/contracts/types/document.types"; import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
import { useIsMobile } from "@/hooks/use-mobile";
import { foldersApiService } from "@/lib/apis/folders-api.service"; import { foldersApiService } from "@/lib/apis/folders-api.service";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { authenticatedFetch } from "@/lib/auth-utils"; import { authenticatedFetch } from "@/lib/auth-utils";
import { queries } from "@/zero/queries/index"; import { queries } from "@/zero/queries/index";
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel"; import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
@ -95,12 +85,10 @@ export function DocumentsSidebar({
const searchSpaceId = Number(params.search_space_id); const searchSpaceId = Number(params.search_space_id);
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom); const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
const setRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom); const setRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom);
const openDocumentTab = useSetAtom(openDocumentTabAtom); const openEditorPanel = useSetAtom(openEditorPanelAtom);
const { data: connectors } = useAtomValue(connectorsAtom); const { data: connectors } = useAtomValue(connectorsAtom);
const connectorCount = connectors?.length ?? 0; const connectorCount = connectors?.length ?? 0;
const isMobileLayout = useIsMobile();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search, 250); const debouncedSearch = useDebouncedValue(search, 250);
const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]); const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]);
@ -374,31 +362,6 @@ export function DocumentsSidebar({
[] []
); );
// Document popup viewer state (for tree view "Open" and mobile preview)
const [viewingDoc, setViewingDoc] = useState<DocumentNodeDoc | null>(null);
const [viewingContent, setViewingContent] = useState<string>("");
const [viewingLoading, setViewingLoading] = useState(false);
const handleViewDocumentPopup = useCallback(async (doc: DocumentNodeDoc) => {
setViewingDoc(doc);
setViewingLoading(true);
try {
const fullDoc = await documentsApiService.getDocument({ id: doc.id });
setViewingContent(fullDoc.content);
} catch (err) {
console.error("[DocumentsSidebar] Failed to fetch document content:", err);
setViewingContent("Failed to load document content.");
} finally {
setViewingLoading(false);
}
}, []);
const handleCloseViewer = useCallback(() => {
setViewingDoc(null);
setViewingContent("");
setViewingLoading(false);
}, []);
const handleToggleChatMention = useCallback( const handleToggleChatMention = useCallback(
(doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => { (doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => {
if (isMentioned) { if (isMentioned) {
@ -557,7 +520,7 @@ export function DocumentsSidebar({
const documentsContent = ( const documentsContent = (
<> <>
<div className="shrink-0 flex h-14 items-center px-4"> <div className="shrink-0 flex h-12 items-center px-4">
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isMobile && ( {isMobile && (
@ -609,7 +572,7 @@ export function DocumentsSidebar({
</div> </div>
{/* Connected tools strip */} {/* Connected tools strip */}
<div className="shrink-0 mx-4 mt-2 mb-3 flex select-none items-center gap-2 rounded-lg border bg-muted/50 transition-colors hover:bg-muted/80"> <div className="shrink-0 mx-4 mt-4 mb-4 flex select-none items-center gap-2 rounded-lg border bg-muted/50 transition-colors hover:bg-muted/80">
<button <button
type="button" type="button"
onClick={() => setConnectorDialogOpen(true)} onClick={() => setConnectorDialogOpen(true)}
@ -716,24 +679,18 @@ export function DocumentsSidebar({
onCreateFolder={handleCreateFolder} onCreateFolder={handleCreateFolder}
searchQuery={debouncedSearch.trim() || undefined} searchQuery={debouncedSearch.trim() || undefined}
onPreviewDocument={(doc) => { onPreviewDocument={(doc) => {
if (isMobileLayout) { openEditorPanel({
handleViewDocumentPopup(doc); documentId: doc.id,
} else { searchSpaceId,
openDocumentTab({ title: doc.title,
documentId: doc.id, });
searchSpaceId,
title: doc.title,
});
}
}} }}
onEditDocument={(doc) => { onEditDocument={(doc) => {
if (!isMobileLayout) { openEditorPanel({
openDocumentTab({ documentId: doc.id,
documentId: doc.id, searchSpaceId,
searchSpaceId, title: doc.title,
title: doc.title, });
});
}
}} }}
onDeleteDocument={(doc) => handleDeleteDocument(doc.id)} onDeleteDocument={(doc) => handleDeleteDocument(doc.id)}
onMoveDocument={handleMoveDocument} onMoveDocument={handleMoveDocument}
@ -761,26 +718,6 @@ export function DocumentsSidebar({
onConfirm={handleCreateFolderConfirm} onConfirm={handleCreateFolderConfirm}
/> />
<Drawer open={!!viewingDoc} onOpenChange={(open) => !open && handleCloseViewer()}>
<DrawerContent className="max-h-[85vh] flex flex-col">
<DrawerHandle />
<DrawerHeader className="text-left shrink-0">
<DrawerTitle className="text-base leading-tight break-words">
{viewingDoc?.title}
</DrawerTitle>
</DrawerHeader>
<div className="overflow-y-auto flex-1 min-h-0 px-4 pb-6 select-text text-xs [&_h1]:text-base! [&_h1]:mt-3! [&_h2]:text-sm! [&_h2]:mt-2! [&_h3]:text-xs! [&_h3]:mt-2!">
{viewingLoading ? (
<div className="flex items-center justify-center py-12">
<Spinner size="lg" className="text-muted-foreground" />
</div>
) : (
<MarkdownViewer content={viewingContent} />
)}
</div>
</DrawerContent>
</Drawer>
<AlertDialog <AlertDialog
open={bulkDeleteConfirmOpen} open={bulkDeleteConfirmOpen}
onOpenChange={(open) => !open && !isBulkDeleting && setBulkDeleteConfirmOpen(false)} onOpenChange={(open) => !open && !isBulkDeleting && setBulkDeleteConfirmOpen(false)}
@ -807,9 +744,10 @@ export function DocumentsSidebar({
handleBulkDeleteSelected(); handleBulkDeleteSelected();
}} }}
disabled={isBulkDeleting} disabled={isBulkDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
> >
{isBulkDeleting ? <Spinner size="sm" /> : "Delete"} <span className={isBulkDeleting ? "opacity-0" : ""}>Delete</span>
{isBulkDeleting && <Spinner size="sm" className="absolute" />}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>

View file

@ -105,7 +105,7 @@ export function Sidebar({
> >
{/* Header - search space name or collapse button when collapsed */} {/* Header - search space name or collapse button when collapsed */}
{isCollapsed ? ( {isCollapsed ? (
<div className="flex h-14 shrink-0 items-center justify-center border-b"> <div className="flex h-12 shrink-0 items-center justify-center border-b">
<SidebarCollapseButton <SidebarCollapseButton
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
onToggle={onToggleCollapse ?? (() => {})} onToggle={onToggleCollapse ?? (() => {})}
@ -113,7 +113,7 @@ export function Sidebar({
/> />
</div> </div>
) : ( ) : (
<div className="flex h-14 shrink-0 items-center gap-0 px-1 border-b"> <div className="flex h-12 shrink-0 items-center gap-0 px-1 border-b">
<SidebarHeader <SidebarHeader
searchSpace={searchSpace} searchSpace={searchSpace}
isCollapsed={isCollapsed} isCollapsed={isCollapsed}

View file

@ -41,6 +41,8 @@ interface DocumentTabContentProps {
title?: string; title?: string;
} }
const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]);
export function DocumentTabContent({ documentId, searchSpaceId, title }: DocumentTabContentProps) { export function DocumentTabContent({ documentId, searchSpaceId, title }: DocumentTabContentProps) {
const [doc, setDoc] = useState<DocumentContent | null>(null); const [doc, setDoc] = useState<DocumentContent | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@ -171,6 +173,8 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
); );
} }
const isEditable = EDITABLE_DOCUMENT_TYPES.has(doc.document_type ?? "");
if (isEditing) { if (isEditing) {
return ( return (
<div className="flex flex-col h-full overflow-hidden"> <div className="flex flex-col h-full overflow-hidden">
@ -218,7 +222,7 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
<h1 className="text-base font-semibold truncate flex-1 min-w-0"> <h1 className="text-base font-semibold truncate flex-1 min-w-0">
{doc.title || title || "Untitled"} {doc.title || title || "Untitled"}
</h1> </h1>
{doc.document_type === "NOTE" && ( {isEditable && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { useAtomValue, useSetAtom } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { FileText, MessageSquare, Plus, X } from "lucide-react"; import { Plus, X } from "lucide-react";
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import { import {
activeTabIdAtom, activeTabIdAtom,
@ -58,11 +58,18 @@ export function TabBar({ onTabSwitch, onNewChat, className }: TabBarProps) {
if (tabs.length <= 1) return null; if (tabs.length <= 1) return null;
return ( return (
<div className={cn("flex items-center shrink-0 border-b bg-main-panel", className)}> <div
<div ref={scrollRef} className="flex items-center flex-1 overflow-x-auto scrollbar-none"> 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"
>
{tabs.map((tab) => { {tabs.map((tab) => {
const isActive = tab.id === activeTabId; const isActive = tab.id === activeTabId;
const Icon = tab.type === "document" ? FileText : MessageSquare;
return ( return (
<button <button
@ -71,15 +78,15 @@ export function TabBar({ onTabSwitch, onNewChat, className }: TabBarProps) {
data-tab-id={tab.id} data-tab-id={tab.id}
onClick={() => handleTabClick(tab)} onClick={() => handleTabClick(tab)}
className={cn( className={cn(
"group relative flex items-center gap-1.5 px-3 h-9 min-w-0 max-w-[200px] text-xs font-medium border-r transition-colors shrink-0", "group relative flex h-full w-[170px] items-center self-stretch px-3 min-w-0 overflow-hidden text-sm font-medium border-r border-border/35 transition-colors shrink-0",
isActive isActive
? "bg-main-panel text-foreground" ? "bg-muted/50 text-foreground"
: "bg-muted/30 text-muted-foreground hover:bg-muted/60 hover:text-foreground" : "bg-transparent text-muted-foreground hover:bg-muted/25 hover:text-foreground"
)} )}
> >
{isActive && <span className="absolute bottom-0 left-0 right-0 h-[2px] bg-primary" />} <span className="block min-w-0 flex-1 truncate text-left transition-[padding-right] duration-150 group-hover:pr-5 group-focus-within:pr-5">
<Icon className="size-3.5 shrink-0" /> {tab.title}
<span className="truncate">{tab.title}</span> </span>
{/* biome-ignore lint/a11y/useSemanticElements: cannot nest button inside button */} {/* biome-ignore lint/a11y/useSemanticElements: cannot nest button inside button */}
<span <span
role="button" role="button"
@ -92,10 +99,10 @@ export function TabBar({ onTabSwitch, onNewChat, className }: TabBarProps) {
} }
}} }}
className={cn( className={cn(
"ml-auto shrink-0 rounded-sm p-0.5 transition-colors", "absolute right-2 top-1/2 -translate-y-1/2 shrink-0 rounded-sm p-0.5 transition-colors",
isActive isActive
? "opacity-60 hover:opacity-100 hover:bg-muted" ? "opacity-0 group-hover:opacity-70 group-focus-within:opacity-70 hover:opacity-100"
: "opacity-0 group-hover:opacity-60 hover:opacity-100! hover:bg-muted" : "opacity-0 group-hover:opacity-60 group-focus-within:opacity-60 hover:opacity-100!"
)} )}
> >
<X className="size-3" /> <X className="size-3" />
@ -103,18 +110,19 @@ export function TabBar({ onTabSwitch, onNewChat, className }: TabBarProps) {
</button> </button>
); );
})} })}
{onNewChat && (
<div className="flex h-full items-center px-1.5 shrink-0">
<button
type="button"
onClick={onNewChat}
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-muted/60"
title="New Chat"
>
<Plus className="size-3.5" />
</button>
</div>
)}
</div> </div>
{onNewChat && (
<button
type="button"
onClick={onNewChat}
className="flex items-center justify-center size-9 shrink-0 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
title="New Chat"
>
<Plus className="size-3.5" />
</button>
)}
</div> </div>
); );
} }

View file

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

View file

@ -1,558 +0,0 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertCircle, Check, ChevronsUpDown, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import {
createImageGenConfigMutationAtom,
updateImageGenConfigMutationAtom,
} from "@/atoms/image-gen-config/image-gen-config-mutation.atoms";
import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { IMAGE_GEN_MODELS, IMAGE_GEN_PROVIDERS } from "@/contracts/enums/image-gen-providers";
import type {
GlobalImageGenConfig,
ImageGenerationConfig,
ImageGenProvider,
} from "@/contracts/types/new-llm-config.types";
import { cn } from "@/lib/utils";
interface ImageConfigDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
config: ImageGenerationConfig | GlobalImageGenConfig | null;
isGlobal: boolean;
searchSpaceId: number;
mode: "create" | "edit" | "view";
}
const INITIAL_FORM = {
name: "",
description: "",
provider: "",
model_name: "",
api_key: "",
api_base: "",
api_version: "",
};
export function ImageConfigDialog({
open,
onOpenChange,
config,
isGlobal,
searchSpaceId,
mode,
}: ImageConfigDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [mounted, setMounted] = useState(false);
const [formData, setFormData] = useState(INITIAL_FORM);
const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (open) {
if (mode === "edit" && config && !isGlobal) {
setFormData({
name: config.name || "",
description: config.description || "",
provider: config.provider || "",
model_name: config.model_name || "",
api_key: (config as ImageGenerationConfig).api_key || "",
api_base: config.api_base || "",
api_version: config.api_version || "",
});
} else if (mode === "create") {
setFormData(INITIAL_FORM);
}
setScrollPos("top");
}
}, [open, mode, config, isGlobal]);
const { mutateAsync: createConfig } = useAtomValue(createImageGenConfigMutationAtom);
const { mutateAsync: updateConfig } = useAtomValue(updateImageGenConfigMutationAtom);
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) onOpenChange(false);
};
window.addEventListener("keydown", handleEscape);
return () => window.removeEventListener("keydown", handleEscape);
}, [open, onOpenChange]);
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const atTop = el.scrollTop <= 2;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
}, []);
const isAutoMode = config && "is_auto_mode" in config && config.is_auto_mode;
const suggestedModels = useMemo(() => {
if (!formData.provider) return [];
return IMAGE_GEN_MODELS.filter((m) => m.provider === formData.provider);
}, [formData.provider]);
const getTitle = () => {
if (mode === "create") return "Add Image Model";
if (isAutoMode) return "Auto Mode (Fastest)";
if (isGlobal) return "View Global Image Model";
return "Edit Image Model";
};
const getSubtitle = () => {
if (mode === "create") return "Set up a new image generation provider";
if (isAutoMode) return "Automatically routes requests across providers";
if (isGlobal) return "Read-only global configuration";
return "Update your image model settings";
};
const handleSubmit = useCallback(async () => {
setIsSubmitting(true);
try {
if (mode === "create") {
const result = await createConfig({
name: formData.name,
provider: formData.provider as ImageGenProvider,
model_name: formData.model_name,
api_key: formData.api_key,
api_base: formData.api_base || undefined,
api_version: formData.api_version || undefined,
description: formData.description || undefined,
search_space_id: searchSpaceId,
});
if (result?.id) {
await updatePreferences({
search_space_id: searchSpaceId,
data: { image_generation_config_id: result.id },
});
}
toast.success("Image model created and assigned!");
onOpenChange(false);
} else if (!isGlobal && config) {
await updateConfig({
id: config.id,
data: {
name: formData.name,
description: formData.description || undefined,
provider: formData.provider as ImageGenProvider,
model_name: formData.model_name,
api_key: formData.api_key,
api_base: formData.api_base || undefined,
api_version: formData.api_version || undefined,
},
});
toast.success("Image model updated!");
onOpenChange(false);
}
} catch (error) {
console.error("Failed to save image config:", error);
toast.error("Failed to save image model");
} finally {
setIsSubmitting(false);
}
}, [
mode,
isGlobal,
config,
formData,
searchSpaceId,
createConfig,
updateConfig,
updatePreferences,
onOpenChange,
]);
const handleUseGlobalConfig = useCallback(async () => {
if (!config || !isGlobal) return;
setIsSubmitting(true);
try {
await updatePreferences({
search_space_id: searchSpaceId,
data: { image_generation_config_id: config.id },
});
toast.success(`Now using ${config.name}`);
onOpenChange(false);
} catch (error) {
console.error("Failed to set image model:", error);
toast.error("Failed to set image model");
} finally {
setIsSubmitting(false);
}
}, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]);
const isFormValid = formData.name && formData.provider && formData.model_name && formData.api_key;
const selectedProvider = IMAGE_GEN_PROVIDERS.find((p) => p.value === formData.provider);
if (!mounted) return null;
const dialogContent = (
<AnimatePresence>
{open && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
onClick={() => onOpenChange(false)}
/>
<motion.div
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.15, ease: "easeOut" }}
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6"
>
<div
role="dialog"
aria-modal="true"
className={cn(
"relative w-full max-w-lg h-[85vh]",
"rounded-xl bg-background shadow-2xl",
"dark:bg-neutral-900",
"flex flex-col overflow-hidden"
)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Escape") onOpenChange(false);
}}
>
{/* Header */}
<div className="flex items-start justify-between px-6 pt-6 pb-4">
<div className="space-y-1 pr-8">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold tracking-tight">{getTitle()}</h2>
{isAutoMode && (
<Badge variant="secondary" className="text-[10px]">
Recommended
</Badge>
)}
{isGlobal && !isAutoMode && mode !== "create" && (
<Badge variant="secondary" className="text-[10px]">
Global
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">{getSubtitle()}</p>
{config && !isAutoMode && mode !== "create" && (
<p className="text-xs font-mono text-muted-foreground/70">
{config.model_name}
</p>
)}
</div>
<Button
variant="ghost"
size="icon"
onClick={() => onOpenChange(false)}
className="absolute right-4 top-4 h-8 w-8 rounded-full text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
</div>
{/* Scrollable content */}
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto px-6 py-5"
style={{
maskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
}}
>
{isAutoMode && (
<Alert className="mb-5 border-violet-500/30 bg-violet-500/5">
<AlertDescription className="text-sm text-violet-700 dark:text-violet-400">
Auto mode distributes image generation requests across all configured
providers for optimal performance and rate limit protection.
</AlertDescription>
</Alert>
)}
{isGlobal && !isAutoMode && config && (
<>
<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">
Global configurations are read-only. To customize, create a new model.
</AlertDescription>
</Alert>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Name
</div>
<p className="text-sm font-medium">{config.name}</p>
</div>
{config.description && (
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Description
</div>
<p className="text-sm text-muted-foreground">{config.description}</p>
</div>
)}
</div>
<Separator />
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Provider
</div>
<p className="text-sm font-medium">{config.provider}</p>
</div>
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Model
</div>
<p className="text-sm font-medium font-mono">{config.model_name}</p>
</div>
</div>
</div>
</>
)}
{(mode === "create" || (mode === "edit" && !isGlobal)) && (
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-sm font-medium">Name *</Label>
<Input
placeholder="e.g., My DALL-E 3"
value={formData.name}
onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Description</Label>
<Input
placeholder="Optional description"
value={formData.description}
onChange={(e) =>
setFormData((p) => ({ ...p, description: e.target.value }))
}
/>
</div>
<Separator />
<div className="space-y-2">
<Label className="text-sm font-medium">Provider *</Label>
<Select
value={formData.provider}
onValueChange={(val) =>
setFormData((p) => ({ ...p, provider: val, model_name: "" }))
}
>
<SelectTrigger>
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent className="bg-muted dark:border-neutral-700">
{IMAGE_GEN_PROVIDERS.map((p) => (
<SelectItem key={p.value} value={p.value} description={p.example}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Model Name *</Label>
{suggestedModels.length > 0 ? (
<Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between font-normal bg-transparent"
>
{formData.model_name || "Select or type a model..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-full p-0 bg-muted dark:border-neutral-700"
align="start"
>
<Command className="bg-transparent">
<CommandInput
placeholder="Search or type model..."
value={formData.model_name}
onValueChange={(val) =>
setFormData((p) => ({ ...p, model_name: val }))
}
/>
<CommandList>
<CommandEmpty>
<span className="text-xs text-muted-foreground">
Type a custom model name
</span>
</CommandEmpty>
<CommandGroup>
{suggestedModels.map((m) => (
<CommandItem
key={m.value}
value={m.value}
onSelect={() => {
setFormData((p) => ({ ...p, model_name: m.value }));
setModelComboboxOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.model_name === m.value
? "opacity-100"
: "opacity-0"
)}
/>
<span className="font-mono text-sm">{m.value}</span>
<span className="ml-2 text-xs text-muted-foreground">
{m.label}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input
placeholder="e.g., dall-e-3"
value={formData.model_name}
onChange={(e) =>
setFormData((p) => ({ ...p, model_name: e.target.value }))
}
/>
)}
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">API Key *</Label>
<Input
type="password"
placeholder="sk-..."
value={formData.api_key}
onChange={(e) => setFormData((p) => ({ ...p, api_key: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">API Base URL</Label>
<Input
placeholder={selectedProvider?.apiBase || "Optional"}
value={formData.api_base}
onChange={(e) => setFormData((p) => ({ ...p, api_base: e.target.value }))}
/>
</div>
{formData.provider === "AZURE_OPENAI" && (
<div className="space-y-2">
<Label className="text-sm font-medium">API Version (Azure)</Label>
<Input
placeholder="2024-02-15-preview"
value={formData.api_version}
onChange={(e) =>
setFormData((p) => ({ ...p, api_version: e.target.value }))
}
/>
</div>
)}
</div>
)}
</div>
{/* Fixed footer */}
<div className="shrink-0 px-6 py-4 flex items-center justify-end gap-3">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
className="text-sm h-9"
>
Cancel
</Button>
{mode === "create" || (mode === "edit" && !isGlobal) ? (
<Button
onClick={handleSubmit}
disabled={isSubmitting || !isFormValid}
className="text-sm h-9 min-w-[120px]"
>
{isSubmitting ? (
<>
<Spinner size="sm" />
{mode === "edit" ? "Saving" : "Creating"}
</>
) : mode === "edit" ? (
"Save Changes"
) : (
"Create & Use"
)}
</Button>
) : isAutoMode ? (
<Button
className="text-sm h-9 gap-2 bg-gradient-to-r from-violet-500 to-purple-600 hover:from-violet-600 hover:to-purple-700"
onClick={handleUseGlobalConfig}
disabled={isSubmitting}
>
{isSubmitting ? "Loading..." : "Use Auto Mode"}
</Button>
) : isGlobal && config ? (
<Button
className="text-sm h-9 gap-2"
onClick={handleUseGlobalConfig}
disabled={isSubmitting}
>
{isSubmitting ? "Loading..." : "Use This Model"}
</Button>
) : null}
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
return typeof document !== "undefined" ? createPortal(dialogContent, document.body) : null;
}

View file

@ -1,489 +0,0 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertCircle, X, Zap } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import {
createNewLLMConfigMutationAtom,
updateLLMPreferencesMutationAtom,
updateNewLLMConfigMutationAtom,
} from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import type {
GlobalNewLLMConfig,
LiteLLMProvider,
NewLLMConfigPublic,
} from "@/contracts/types/new-llm-config.types";
import { cn } from "@/lib/utils";
interface ModelConfigDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
config: NewLLMConfigPublic | GlobalNewLLMConfig | null;
isGlobal: boolean;
searchSpaceId: number;
mode: "create" | "edit" | "view";
}
export function ModelConfigDialog({
open,
onOpenChange,
config,
isGlobal,
searchSpaceId,
mode,
}: ModelConfigDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [mounted, setMounted] = useState(false);
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setMounted(true);
}, []);
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const atTop = el.scrollTop <= 2;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
}, []);
const { mutateAsync: createConfig } = useAtomValue(createNewLLMConfigMutationAtom);
const { mutateAsync: updateConfig } = useAtomValue(updateNewLLMConfigMutationAtom);
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) {
onOpenChange(false);
}
};
window.addEventListener("keydown", handleEscape);
return () => window.removeEventListener("keydown", handleEscape);
}, [open, onOpenChange]);
const isAutoMode = config && "is_auto_mode" in config && config.is_auto_mode;
const getTitle = () => {
if (mode === "create") return "Add New Configuration";
if (isAutoMode) return "Auto Mode (Fastest)";
if (isGlobal) return "View Global Configuration";
return "Edit Configuration";
};
const getSubtitle = () => {
if (mode === "create") return "Set up a new LLM provider for this search space";
if (isAutoMode) return "Automatically routes requests across providers";
if (isGlobal) return "Read-only global configuration";
return "Update your configuration settings";
};
const handleSubmit = useCallback(
async (data: LLMConfigFormData) => {
setIsSubmitting(true);
try {
if (mode === "create") {
const result = await createConfig({
...data,
search_space_id: searchSpaceId,
});
if (result?.id) {
await updatePreferences({
search_space_id: searchSpaceId,
data: {
agent_llm_id: result.id,
},
});
}
toast.success("Configuration created and assigned!");
onOpenChange(false);
} else if (!isGlobal && config) {
await updateConfig({
id: config.id,
data: {
name: data.name,
description: data.description,
provider: data.provider,
custom_provider: data.custom_provider,
model_name: data.model_name,
api_key: data.api_key,
api_base: data.api_base,
litellm_params: data.litellm_params,
system_instructions: data.system_instructions,
use_default_system_instructions: data.use_default_system_instructions,
citations_enabled: data.citations_enabled,
},
});
toast.success("Configuration updated!");
onOpenChange(false);
}
} catch (error) {
console.error("Failed to save configuration:", error);
toast.error("Failed to save configuration");
} finally {
setIsSubmitting(false);
}
},
[
mode,
isGlobal,
config,
searchSpaceId,
createConfig,
updateConfig,
updatePreferences,
onOpenChange,
]
);
const handleUseGlobalConfig = useCallback(async () => {
if (!config || !isGlobal) return;
setIsSubmitting(true);
try {
await updatePreferences({
search_space_id: searchSpaceId,
data: {
agent_llm_id: config.id,
},
});
toast.success(`Now using ${config.name}`);
onOpenChange(false);
} catch (error) {
console.error("Failed to set model:", error);
toast.error("Failed to set model");
} finally {
setIsSubmitting(false);
}
}, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]);
if (!mounted) return null;
const dialogContent = (
<AnimatePresence>
{open && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="fixed inset-0 z-[24] bg-black/50 backdrop-blur-sm"
onClick={() => onOpenChange(false)}
/>
{/* Dialog */}
<motion.div
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.15, ease: "easeOut" }}
className="fixed inset-0 z-[25] flex items-center justify-center p-4 sm:p-6"
>
<div
role="dialog"
aria-modal="true"
className={cn(
"relative w-full max-w-lg h-[85vh]",
"rounded-xl bg-background shadow-2xl",
"dark:bg-neutral-900",
"flex flex-col overflow-hidden"
)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Escape") onOpenChange(false);
}}
>
{/* Header */}
<div className="flex items-start justify-between px-6 pt-6 pb-4">
<div className="space-y-1 pr-8">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold tracking-tight">{getTitle()}</h2>
{isAutoMode && (
<Badge variant="secondary" className="text-[10px]">
Recommended
</Badge>
)}
{isGlobal && !isAutoMode && mode !== "create" && (
<Badge variant="secondary" className="text-[10px]">
Global
</Badge>
)}
{!isGlobal && mode !== "create" && !isAutoMode && (
<Badge variant="outline" className="text-[10px]">
Custom
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">{getSubtitle()}</p>
{config && !isAutoMode && mode !== "create" && (
<p className="text-xs font-mono text-muted-foreground/70">
{config.model_name}
</p>
)}
</div>
<Button
variant="ghost"
size="icon"
onClick={() => onOpenChange(false)}
className="absolute right-4 top-4 h-8 w-8 rounded-full text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
</div>
{/* Scrollable content */}
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto px-6 py-5"
style={{
maskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
}}
>
{isAutoMode && (
<Alert className="mb-5 border-violet-500/30 bg-violet-500/5">
<AlertDescription className="text-sm text-violet-700 dark:text-violet-400">
Auto mode automatically distributes requests across all available LLM
providers to optimize performance and avoid rate limits.
</AlertDescription>
</Alert>
)}
{isGlobal && !isAutoMode && 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">
Global configurations are read-only. To customize settings, create a new
configuration based on this template.
</AlertDescription>
</Alert>
)}
{mode === "create" ? (
<LLMConfigForm
searchSpaceId={searchSpaceId}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
mode="create"
formId="model-config-form"
hideActions
/>
) : isAutoMode && config ? (
<div className="space-y-6">
<div className="space-y-4">
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
How It Works
</div>
<p className="text-sm text-muted-foreground">{config.description}</p>
</div>
<div className="h-px bg-border/50" />
<div className="space-y-3">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Key Benefits
</div>
<div className="space-y-2">
<div className="flex items-start gap-3 p-3 rounded-lg bg-violet-50 dark:bg-violet-900/20 border border-violet-200 dark:border-violet-800/50">
<Zap className="size-4 text-violet-600 dark:text-violet-400 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium text-violet-900 dark:text-violet-100">
Automatic (Fastest)
</p>
<p className="text-xs text-violet-700 dark:text-violet-300">
Distributes requests across all configured LLM providers
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 rounded-lg bg-violet-50 dark:bg-violet-900/20 border border-violet-200 dark:border-violet-800/50">
<Zap className="size-4 text-violet-600 dark:text-violet-400 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium text-violet-900 dark:text-violet-100">
Rate Limit Protection
</p>
<p className="text-xs text-violet-700 dark:text-violet-300">
Automatically handles rate limits with cooldowns and retries
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 rounded-lg bg-violet-50 dark:bg-violet-900/20 border border-violet-200 dark:border-violet-800/50">
<Zap className="size-4 text-violet-600 dark:text-violet-400 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium text-violet-900 dark:text-violet-100">
Automatic Failover
</p>
<p className="text-xs text-violet-700 dark:text-violet-300">
Falls back to other providers if one becomes unavailable
</p>
</div>
</div>
</div>
</div>
</div>
</div>
) : isGlobal && config ? (
<div className="space-y-6">
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Configuration Name
</div>
<p className="text-sm font-medium">{config.name}</p>
</div>
{config.description && (
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Description
</div>
<p className="text-sm text-muted-foreground">{config.description}</p>
</div>
)}
</div>
<div className="h-px bg-border/50" />
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Provider
</div>
<p className="text-sm font-medium">{config.provider}</p>
</div>
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Model
</div>
<p className="text-sm font-medium font-mono">{config.model_name}</p>
</div>
</div>
<div className="h-px bg-border/50" />
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Citations
</div>
<Badge
variant={config.citations_enabled ? "default" : "secondary"}
className="w-fit"
>
{config.citations_enabled ? "Enabled" : "Disabled"}
</Badge>
</div>
</div>
{config.system_instructions && (
<>
<div className="h-px bg-border/50" />
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
System Instructions
</div>
<div className="p-3 rounded-lg bg-muted/50 border border-border/50">
<p className="text-xs font-mono text-muted-foreground whitespace-pre-wrap line-clamp-10">
{config.system_instructions}
</p>
</div>
</div>
</>
)}
</div>
</div>
) : config ? (
<LLMConfigForm
searchSpaceId={searchSpaceId}
initialData={{
name: config.name,
description: config.description,
provider: config.provider as LiteLLMProvider,
custom_provider: config.custom_provider,
model_name: config.model_name,
api_key: "api_key" in config ? (config.api_key as string) : "",
api_base: config.api_base,
litellm_params: config.litellm_params,
system_instructions: config.system_instructions,
use_default_system_instructions: config.use_default_system_instructions,
citations_enabled: config.citations_enabled,
search_space_id: searchSpaceId,
}}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
mode="edit"
formId="model-config-form"
hideActions
/>
) : null}
</div>
{/* Fixed footer */}
<div className="shrink-0 px-6 py-4 flex items-center justify-end gap-3">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
className="text-sm h-9"
>
Cancel
</Button>
{mode === "create" || (!isGlobal && !isAutoMode && config) ? (
<Button
type="submit"
form="model-config-form"
disabled={isSubmitting}
className="text-sm h-9 min-w-[120px]"
>
{isSubmitting ? (
<>
<Spinner size="sm" />
{mode === "edit" ? "Saving" : "Creating"}
</>
) : mode === "edit" ? (
"Save Changes"
) : (
"Create & Use"
)}
</Button>
) : isAutoMode ? (
<Button
className="text-sm h-9 gap-2 bg-gradient-to-r from-violet-500 to-purple-600 hover:from-violet-600 hover:to-purple-700"
onClick={handleUseGlobalConfig}
disabled={isSubmitting}
>
{isSubmitting ? "Loading..." : "Use Auto Mode"}
</Button>
) : isGlobal && config ? (
<Button
className="text-sm h-9 gap-2"
onClick={handleUseGlobalConfig}
disabled={isSubmitting}
>
{isSubmitting ? "Loading..." : "Use This Model"}
</Button>
) : null}
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
return typeof document !== "undefined" ? createPortal(dialogContent, document.body) : null;
}

View file

@ -498,7 +498,7 @@ export function ModelSelector({
}} }}
> >
<Plus className="size-4 text-primary" /> <Plus className="size-4 text-primary" />
<span className="text-sm font-medium">Add New Configuration</span> <span className="text-sm font-medium">Add LLM Model</span>
</Button> </Button>
</div> </div>
</CommandList> </CommandList>

View file

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

View file

@ -10,6 +10,7 @@ import {
import { CheckIcon, CopyIcon } from "lucide-react"; import { CheckIcon, CopyIcon } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import { type FC, type ReactNode, useState } from "react"; import { type FC, type ReactNode, useState } from "react";
import { CitationMetadataProvider } from "@/components/assistant-ui/citation-metadata-context";
import { MarkdownText } from "@/components/assistant-ui/markdown-text"; import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
@ -142,30 +143,33 @@ const PublicAssistantMessage: FC = () => {
className="aui-assistant-message-root group fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-3 duration-150" className="aui-assistant-message-root group fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
data-role="assistant" data-role="assistant"
> >
<div className="aui-assistant-message-content wrap-break-word px-2 text-foreground leading-relaxed"> <CitationMetadataProvider>
<MessagePrimitive.Parts <div className="aui-assistant-message-content wrap-break-word px-2 text-foreground leading-relaxed">
components={{ <MessagePrimitive.Parts
Text: MarkdownText, components={{
tools: { Text: MarkdownText,
by_name: { tools: {
generate_podcast: GeneratePodcastToolUI, by_name: {
generate_report: GenerateReportToolUI, generate_podcast: GeneratePodcastToolUI,
generate_video_presentation: GenerateVideoPresentationToolUI, generate_report: GenerateReportToolUI,
display_image: GenerateImageToolUI, generate_video_presentation: GenerateVideoPresentationToolUI,
generate_image: GenerateImageToolUI, display_image: GenerateImageToolUI,
link_preview: () => null, generate_image: GenerateImageToolUI,
multi_link_preview: () => null, web_search: () => null,
scrape_webpage: () => null, link_preview: () => null,
multi_link_preview: () => null,
scrape_webpage: () => null,
},
Fallback: ToolFallback,
}, },
Fallback: ToolFallback, }}
}, />
}} </div>
/>
</div>
<div className="aui-assistant-message-footer mt-1 mb-5 ml-2 flex"> <div className="aui-assistant-message-footer mt-1 mb-5 ml-2 flex">
<PublicAssistantActionBar /> <PublicAssistantActionBar />
</div> </div>
</CitationMetadataProvider>
</MessagePrimitive.Root> </MessagePrimitive.Root>
); );
}; };

View file

@ -1,31 +1,15 @@
"use client"; "use client";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { import { AlertCircle, Edit3, Info, Plus, RefreshCw, Trash2, Wand2 } from "lucide-react";
AlertCircle, import { useMemo, useState } from "react";
Check, import { deleteImageGenConfigMutationAtom } from "@/atoms/image-gen-config/image-gen-config-mutation.atoms";
ChevronsUpDown,
Edit3,
Info,
Key,
Plus,
RefreshCw,
Trash2,
Wand2,
} from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
import {
createImageGenConfigMutationAtom,
deleteImageGenConfigMutationAtom,
updateImageGenConfigMutationAtom,
} from "@/atoms/image-gen-config/image-gen-config-mutation.atoms";
import { import {
globalImageGenConfigsAtom, globalImageGenConfigsAtom,
imageGenConfigsAtom, imageGenConfigsAtom,
} from "@/atoms/image-gen-config/image-gen-config-query.atoms"; } from "@/atoms/image-gen-config/image-gen-config-query.atoms";
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms"; import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms"; import { ImageConfigDialog } from "@/components/shared/image-config-dialog";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { import {
AlertDialog, AlertDialog,
@ -40,39 +24,9 @@ import {
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import {
getImageGenModelsByProvider,
IMAGE_GEN_PROVIDERS,
} from "@/contracts/enums/image-gen-providers";
import type { ImageGenerationConfig } from "@/contracts/types/new-llm-config.types"; import type { ImageGenerationConfig } from "@/contracts/types/new-llm-config.types";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
import { getProviderIcon } from "@/lib/provider-icons"; import { getProviderIcon } from "@/lib/provider-icons";
@ -92,23 +46,12 @@ function getInitials(name: string): string {
export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) { export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
const isDesktop = useMediaQuery("(min-width: 768px)"); const isDesktop = useMediaQuery("(min-width: 768px)");
// Image gen config atoms
const {
mutateAsync: createConfig,
isPending: isCreating,
error: createError,
} = useAtomValue(createImageGenConfigMutationAtom);
const {
mutateAsync: updateConfig,
isPending: isUpdating,
error: updateError,
} = useAtomValue(updateImageGenConfigMutationAtom);
const { const {
mutateAsync: deleteConfig, mutateAsync: deleteConfig,
isPending: isDeleting, isPending: isDeleting,
error: deleteError, error: deleteError,
} = useAtomValue(deleteImageGenConfigMutationAtom); } = useAtomValue(deleteImageGenConfigMutationAtom);
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
const { const {
data: userConfigs, data: userConfigs,
@ -119,7 +62,6 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
const { data: globalConfigs = [], isFetching: globalLoading } = const { data: globalConfigs = [], isFetching: globalLoading } =
useAtomValue(globalImageGenConfigsAtom); useAtomValue(globalImageGenConfigsAtom);
// Members for user resolution
const { data: members } = useAtomValue(membersAtom); const { data: members } = useAtomValue(membersAtom);
const memberMap = useMemo(() => { const memberMap = useMemo(() => {
const map = new Map<string, { name: string; email?: string; avatarUrl?: string }>(); const map = new Map<string, { name: string; email?: string; avatarUrl?: string }>();
@ -135,7 +77,6 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
return map; return map;
}, [members]); }, [members]);
// Permissions
const { data: access } = useAtomValue(myAccessAtom); const { data: access } = useAtomValue(myAccessAtom);
const canCreate = useMemo(() => { const canCreate = useMemo(() => {
if (!access) return false; if (!access) return false;
@ -147,126 +88,35 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
if (access.is_owner) return true; if (access.is_owner) return true;
return access.permissions?.includes("image_generations:delete") ?? false; return access.permissions?.includes("image_generations:delete") ?? false;
}, [access]); }, [access]);
// Backend uses image_generations:create for update as well
const canUpdate = canCreate; const canUpdate = canCreate;
const isReadOnly = !canCreate && !canDelete; const isReadOnly = !canCreate && !canDelete;
// Local state
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingConfig, setEditingConfig] = useState<ImageGenerationConfig | null>(null); const [editingConfig, setEditingConfig] = useState<ImageGenerationConfig | null>(null);
const [configToDelete, setConfigToDelete] = useState<ImageGenerationConfig | null>(null); const [configToDelete, setConfigToDelete] = useState<ImageGenerationConfig | null>(null);
const isSubmitting = isCreating || isUpdating;
const isLoading = configsLoading || globalLoading; const isLoading = configsLoading || globalLoading;
const errors = [createError, updateError, deleteError, fetchError].filter(Boolean) as Error[]; const errors = [deleteError, fetchError].filter(Boolean) as Error[];
// Form state for create/edit dialog
const [formData, setFormData] = useState({
name: "",
description: "",
provider: "",
custom_provider: "",
model_name: "",
api_key: "",
api_base: "",
api_version: "",
});
const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
const resetForm = () => {
setFormData({
name: "",
description: "",
provider: "",
custom_provider: "",
model_name: "",
api_key: "",
api_base: "",
api_version: "",
});
};
const handleFormSubmit = useCallback(async () => {
if (!formData.name || !formData.provider || !formData.model_name || !formData.api_key) {
toast.error("Please fill in all required fields");
return;
}
try {
if (editingConfig) {
await updateConfig({
id: editingConfig.id,
data: {
name: formData.name,
description: formData.description || undefined,
provider: formData.provider as any,
custom_provider: formData.custom_provider || undefined,
model_name: formData.model_name,
api_key: formData.api_key,
api_base: formData.api_base || undefined,
api_version: formData.api_version || undefined,
},
});
} else {
const result = await createConfig({
name: formData.name,
description: formData.description || undefined,
provider: formData.provider as any,
custom_provider: formData.custom_provider || undefined,
model_name: formData.model_name,
api_key: formData.api_key,
api_base: formData.api_base || undefined,
api_version: formData.api_version || undefined,
search_space_id: searchSpaceId,
});
// Auto-assign newly created config
if (result?.id) {
await updatePreferences({
search_space_id: searchSpaceId,
data: { image_generation_config_id: result.id },
});
}
}
setIsDialogOpen(false);
setEditingConfig(null);
resetForm();
} catch {
// Error handled by mutation
}
}, [editingConfig, formData, searchSpaceId, createConfig, updateConfig, updatePreferences]);
const handleDelete = async () => {
if (!configToDelete) return;
try {
await deleteConfig(configToDelete.id);
setConfigToDelete(null);
} catch {
// Error handled by mutation
}
};
const openEditDialog = (config: ImageGenerationConfig) => { const openEditDialog = (config: ImageGenerationConfig) => {
setEditingConfig(config); setEditingConfig(config);
setFormData({
name: config.name,
description: config.description || "",
provider: config.provider,
custom_provider: config.custom_provider || "",
model_name: config.model_name,
api_key: config.api_key,
api_base: config.api_base || "",
api_version: config.api_version || "",
});
setIsDialogOpen(true); setIsDialogOpen(true);
}; };
const openNewDialog = () => { const openNewDialog = () => {
setEditingConfig(null); setEditingConfig(null);
resetForm();
setIsDialogOpen(true); setIsDialogOpen(true);
}; };
const selectedProvider = IMAGE_GEN_PROVIDERS.find((p) => p.value === formData.provider); const handleDelete = async () => {
const suggestedModels = getImageGenModelsByProvider(formData.provider); if (!configToDelete) return;
try {
await deleteConfig({ id: configToDelete.id, name: configToDelete.name });
setConfigToDelete(null);
} catch {
// Error handled by mutation
}
};
return ( return (
<div className="space-y-4 md:space-y-6"> <div className="space-y-4 md:space-y-6">
@ -336,11 +186,16 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
<Alert className="bg-muted/50 py-3"> <Alert className="bg-muted/50 py-3">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" /> <Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm"> <AlertDescription className="text-xs md:text-sm">
<span className="font-medium"> <p>
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length} global <span className="font-medium">
image model(s) {globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length}{" "}
</span>{" "} global image{" "}
available from your administrator. {globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length === 1
? "model"
: "models"}
</span>{" "}
available from your administrator. Use the model selector to view and select them.
</p>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
@ -348,31 +203,26 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
{/* Loading Skeleton */} {/* Loading Skeleton */}
{isLoading && ( {isLoading && (
<div className="space-y-4 md:space-y-6"> <div className="space-y-4 md:space-y-6">
{/* Your Image Models Section Skeleton */}
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Skeleton className="h-6 md:h-7 w-40 md:w-48" /> <Skeleton className="h-6 md:h-7 w-40 md:w-48" />
<Skeleton className="h-8 md:h-9 w-32 md:w-36 rounded-md" /> <Skeleton className="h-8 md:h-9 w-32 md:w-36 rounded-md" />
</div> </div>
{/* Cards Grid Skeleton */}
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => ( {["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
<Card key={key} className="border-border/60"> <Card key={key} className="border-border/60">
<CardContent className="p-4 flex flex-col gap-3"> <CardContent className="p-4 flex flex-col gap-3">
{/* Header */}
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="space-y-1.5 flex-1 min-w-0"> <div className="space-y-1.5 flex-1 min-w-0">
<Skeleton className="h-4 w-28 md:w-32" /> <Skeleton className="h-4 w-28 md:w-32" />
<Skeleton className="h-3 w-40 md:w-48" /> <Skeleton className="h-3 w-40 md:w-48" />
</div> </div>
</div> </div>
{/* Provider + Model */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Skeleton className="h-5 w-16 rounded-full" /> <Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="h-5 w-24 rounded-md" /> <Skeleton className="h-5 w-24 rounded-md" />
</div> </div>
{/* Footer */}
<div className="flex items-center gap-2 pt-2 border-t border-border/40"> <div className="flex items-center gap-2 pt-2 border-t border-border/40">
<Skeleton className="h-3 w-20" /> <Skeleton className="h-3 w-20" />
<Skeleton className="h-4 w-4 rounded-full" /> <Skeleton className="h-4 w-4 rounded-full" />
@ -529,216 +379,27 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
</div> </div>
)} )}
{/* Create/Edit Dialog */} {/* Create/Edit Dialog — shared component */}
<Dialog <ImageConfigDialog
open={isDialogOpen} open={isDialogOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { setIsDialogOpen(open);
setIsDialogOpen(false); if (!open) setEditingConfig(null);
setEditingConfig(null);
resetForm();
}
}} }}
> config={editingConfig}
<DialogContent isGlobal={false}
className="max-w-lg max-h-[90vh] overflow-y-auto" searchSpaceId={searchSpaceId}
onOpenAutoFocus={(e) => e.preventDefault()} mode={editingConfig ? "edit" : "create"}
> />
<DialogHeader>
<DialogTitle>{editingConfig ? "Edit Image Model" : "Add Image Model"}</DialogTitle>
<DialogDescription>
{editingConfig
? "Update your image generation model"
: "Configure a new image generation model (DALL-E 3, GPT Image 1, etc.)"}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 pt-2">
{/* Name */}
<div className="space-y-2">
<Label className="text-sm font-medium">Name *</Label>
<Input
placeholder="e.g., My DALL-E 3"
value={formData.name}
onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))}
/>
</div>
{/* Description */}
<div className="space-y-2">
<Label className="text-sm font-medium">Description</Label>
<Input
placeholder="Optional description"
value={formData.description}
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
/>
</div>
<Separator />
{/* Provider */}
<div className="space-y-2">
<Label className="text-sm font-medium">Provider *</Label>
<Select
value={formData.provider}
onValueChange={(val) =>
setFormData((p) => ({ ...p, provider: val, model_name: "" }))
}
>
<SelectTrigger>
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
{IMAGE_GEN_PROVIDERS.map((p) => (
<SelectItem key={p.value} value={p.value}>
<div className="flex flex-col">
<span className="font-medium">{p.label}</span>
<span className="text-xs text-muted-foreground">{p.example}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Model Name */}
<div className="space-y-2">
<Label className="text-sm font-medium">Model Name *</Label>
{suggestedModels.length > 0 ? (
<Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between font-normal"
>
{formData.model_name || "Select a model"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput
placeholder="Search a model name"
value={formData.model_name}
onValueChange={(val) => setFormData((p) => ({ ...p, model_name: val }))}
/>
<CommandList>
<CommandEmpty>
<span className="text-xs text-muted-foreground">
Type a custom model name
</span>
</CommandEmpty>
<CommandGroup>
{suggestedModels.map((m) => (
<CommandItem
key={m.value}
value={m.value}
onSelect={() => {
setFormData((p) => ({ ...p, model_name: m.value }));
setModelComboboxOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.model_name === m.value ? "opacity-100" : "opacity-0"
)}
/>
<span className="font-mono text-sm">{m.value}</span>
<span className="ml-2 text-xs text-muted-foreground">{m.label}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input
placeholder="e.g., dall-e-3"
value={formData.model_name}
onChange={(e) => setFormData((p) => ({ ...p, model_name: e.target.value }))}
/>
)}
</div>
{/* API Key */}
<div className="space-y-2">
<Label className="text-sm font-medium flex items-center gap-1.5">
<Key className="h-3.5 w-3.5" /> API Key *
</Label>
<Input
type="password"
placeholder="sk-..."
value={formData.api_key}
onChange={(e) => setFormData((p) => ({ ...p, api_key: e.target.value }))}
/>
</div>
{/* API Base (optional) */}
<div className="space-y-2">
<Label className="text-sm font-medium">API Base URL</Label>
<Input
placeholder={selectedProvider?.apiBase || "Optional"}
value={formData.api_base}
onChange={(e) => setFormData((p) => ({ ...p, api_base: e.target.value }))}
/>
</div>
{/* API Version (Azure) */}
{formData.provider === "AZURE_OPENAI" && (
<div className="space-y-2">
<Label className="text-sm font-medium">API Version (Azure)</Label>
<Input
placeholder="2024-02-15-preview"
value={formData.api_version}
onChange={(e) => setFormData((p) => ({ ...p, api_version: e.target.value }))}
/>
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-3 pt-4 border-t">
<Button
variant="secondary"
onClick={() => {
setIsDialogOpen(false);
setEditingConfig(null);
resetForm();
}}
>
Cancel
</Button>
<Button
onClick={handleFormSubmit}
disabled={
isSubmitting ||
!formData.name ||
!formData.provider ||
!formData.model_name ||
!formData.api_key
}
>
{isSubmitting ? <Spinner size="sm" className="mr-2" /> : null}
{editingConfig ? "Save Changes" : "Create & Use"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Delete Confirmation */} {/* Delete Confirmation */}
<AlertDialog <AlertDialog
open={!!configToDelete} open={!!configToDelete}
onOpenChange={(open) => !open && setConfigToDelete(null)} onOpenChange={(open) => !open && setConfigToDelete(null)}
> >
<AlertDialogContent> <AlertDialogContent className="select-none">
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2"> <AlertDialogTitle>Delete Image Model</AlertDialogTitle>
<Trash2 className="h-5 w-5 text-destructive" />
Delete Image Model
</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Are you sure you want to delete{" "} Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">{configToDelete?.name}</span>? <span className="font-semibold text-foreground">{configToDelete?.name}</span>?
@ -749,19 +410,10 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
<AlertDialogAction <AlertDialogAction
onClick={handleDelete} onClick={handleDelete}
disabled={isDeleting} disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
> >
{isDeleting ? ( <span className={isDeleting ? "opacity-0" : ""}>Delete</span>
<> {isDeleting && <Spinner size="sm" className="absolute" />}
<Spinner size="sm" className="mr-2" />
Deleting
</>
) : (
<>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</>
)}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>

View file

@ -12,18 +12,14 @@ import {
Trash2, Trash2,
Wand2, Wand2,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms"; import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
import { import { deleteNewLLMConfigMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
createNewLLMConfigMutationAtom,
deleteNewLLMConfigMutationAtom,
updateNewLLMConfigMutationAtom,
} from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
import { import {
globalNewLLMConfigsAtom, globalNewLLMConfigsAtom,
newLLMConfigsAtom, newLLMConfigsAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms"; } from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form"; import { ModelConfigDialog } from "@/components/shared/model-config-dialog";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { import {
AlertDialog, AlertDialog,
@ -39,13 +35,6 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
@ -69,12 +58,6 @@ function getInitials(name: string): string {
export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) { export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
const isDesktop = useMediaQuery("(min-width: 768px)"); const isDesktop = useMediaQuery("(min-width: 768px)");
// Mutations // Mutations
const { mutateAsync: createConfig, isPending: isCreating } = useAtomValue(
createNewLLMConfigMutationAtom
);
const { mutateAsync: updateConfig, isPending: isUpdating } = useAtomValue(
updateNewLLMConfigMutationAtom
);
const { mutateAsync: deleteConfig, isPending: isDeleting } = useAtomValue( const { mutateAsync: deleteConfig, isPending: isDeleting } = useAtomValue(
deleteNewLLMConfigMutationAtom deleteNewLLMConfigMutationAtom
); );
@ -128,33 +111,10 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
const [editingConfig, setEditingConfig] = useState<NewLLMConfig | null>(null); const [editingConfig, setEditingConfig] = useState<NewLLMConfig | null>(null);
const [configToDelete, setConfigToDelete] = useState<NewLLMConfig | null>(null); const [configToDelete, setConfigToDelete] = useState<NewLLMConfig | null>(null);
const isSubmitting = isCreating || isUpdating;
const handleFormSubmit = useCallback(
async (formData: LLMConfigFormData) => {
try {
if (editingConfig) {
const { search_space_id, ...updateData } = formData;
await updateConfig({
id: editingConfig.id,
data: updateData,
});
} else {
await createConfig(formData);
}
setIsDialogOpen(false);
setEditingConfig(null);
} catch {
// Error is displayed inside the dialog by the form
}
},
[editingConfig, createConfig, updateConfig]
);
const handleDelete = async () => { const handleDelete = async () => {
if (!configToDelete) return; if (!configToDelete) return;
try { try {
await deleteConfig({ id: configToDelete.id }); await deleteConfig({ id: configToDelete.id, name: configToDelete.name });
setConfigToDelete(null); setConfigToDelete(null);
} catch { } catch {
// Error handled by mutation state // Error handled by mutation state
@ -171,11 +131,6 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
setIsDialogOpen(true); setIsDialogOpen(true);
}; };
const closeDialog = () => {
setIsDialogOpen(false);
setEditingConfig(null);
};
return ( return (
<div className="space-y-5 md:space-y-6"> <div className="space-y-5 md:space-y-6">
{/* Header actions */} {/* Header actions */}
@ -196,7 +151,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
onClick={openNewDialog} onClick={openNewDialog}
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200" className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
> >
Add Configuration Add LLM Model
</Button> </Button>
)} )}
</div> </div>
@ -243,18 +198,17 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
{/* Global Configs Info */} {/* Global Configs Info */}
{globalConfigs.length > 0 && ( {globalConfigs.length > 0 && (
<div> <Alert className="bg-muted/50 py-3">
<Alert className="bg-muted/50 py-3 md:py-4"> <Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" /> <AlertDescription className="text-xs md:text-sm">
<AlertDescription className="text-xs md:text-sm"> <p>
<span className="font-medium">{globalConfigs.length} global configuration(s)</span>{" "} <span className="font-medium">
available from your administrator. These are pre-configured and ready to use.{" "} {globalConfigs.length} global {globalConfigs.length === 1 ? "model" : "models"}
<span className="text-muted-foreground"> </span>{" "}
Global configs: {globalConfigs.map((g) => g.name).join(", ")} available from your administrator. Use the model selector to view and select them.
</span> </p>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
</div>
)} )}
{/* Loading Skeleton */} {/* Loading Skeleton */}
@ -463,66 +417,26 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
)} )}
{/* Add/Edit Configuration Dialog */} {/* Add/Edit Configuration Dialog */}
<Dialog open={isDialogOpen} onOpenChange={(open) => !open && closeDialog()}> <ModelConfigDialog
<DialogContent open={isDialogOpen}
className="max-w-2xl max-h-[90vh] overflow-y-auto" onOpenChange={(open) => {
onOpenAutoFocus={(e) => e.preventDefault()} setIsDialogOpen(open);
> if (!open) setEditingConfig(null);
<DialogHeader> }}
<DialogTitle> config={editingConfig}
{editingConfig ? "Edit Configuration" : "Create New Configuration"} isGlobal={false}
</DialogTitle> searchSpaceId={searchSpaceId}
<DialogDescription> mode={editingConfig ? "edit" : "create"}
{editingConfig />
? "Update your AI model and prompt configuration"
: "Set up a new AI model with custom prompts and citation settings"}
</DialogDescription>
</DialogHeader>
<LLMConfigForm
key={editingConfig ? `edit-${editingConfig.id}` : "create"}
searchSpaceId={searchSpaceId}
initialData={
editingConfig
? {
name: editingConfig.name,
description: editingConfig.description || "",
provider: editingConfig.provider,
custom_provider: editingConfig.custom_provider || "",
model_name: editingConfig.model_name,
api_key: editingConfig.api_key,
api_base: editingConfig.api_base || "",
litellm_params: editingConfig.litellm_params || {},
system_instructions: editingConfig.system_instructions || "",
use_default_system_instructions: editingConfig.use_default_system_instructions,
citations_enabled: editingConfig.citations_enabled,
}
: {
citations_enabled: true,
use_default_system_instructions: true,
}
}
onSubmit={handleFormSubmit}
onCancel={closeDialog}
isSubmitting={isSubmitting}
mode={editingConfig ? "edit" : "create"}
showAdvanced={true}
compact={true}
/>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */} {/* Delete Confirmation Dialog */}
<AlertDialog <AlertDialog
open={!!configToDelete} open={!!configToDelete}
onOpenChange={(open) => !open && setConfigToDelete(null)} onOpenChange={(open) => !open && setConfigToDelete(null)}
> >
<AlertDialogContent> <AlertDialogContent className="select-none">
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2"> <AlertDialogTitle>Delete LLM Model</AlertDialogTitle>
<Trash2 className="h-5 w-5 text-destructive" />
Delete Configuration
</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Are you sure you want to delete{" "} Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">{configToDelete?.name}</span>? This <span className="font-semibold text-foreground">{configToDelete?.name}</span>? This
@ -542,10 +456,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
Deleting Deleting
</> </>
) : ( ) : (
<> "Delete"
<Trash2 className="mr-2 h-4 w-4" />
Delete
</>
)} )}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>

View file

@ -0,0 +1,454 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertCircle, Check, ChevronsUpDown } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
createImageGenConfigMutationAtom,
updateImageGenConfigMutationAtom,
} from "@/atoms/image-gen-config/image-gen-config-mutation.atoms";
import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { IMAGE_GEN_MODELS, IMAGE_GEN_PROVIDERS } from "@/contracts/enums/image-gen-providers";
import type {
GlobalImageGenConfig,
ImageGenerationConfig,
ImageGenProvider,
} from "@/contracts/types/new-llm-config.types";
import { cn } from "@/lib/utils";
interface ImageConfigDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
config: ImageGenerationConfig | GlobalImageGenConfig | null;
isGlobal: boolean;
searchSpaceId: number;
mode: "create" | "edit" | "view";
}
const INITIAL_FORM = {
name: "",
description: "",
provider: "",
model_name: "",
api_key: "",
api_base: "",
api_version: "",
};
export function ImageConfigDialog({
open,
onOpenChange,
config,
isGlobal,
searchSpaceId,
mode,
}: ImageConfigDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState(INITIAL_FORM);
const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (open) {
if (mode === "edit" && config && !isGlobal) {
setFormData({
name: config.name || "",
description: config.description || "",
provider: config.provider || "",
model_name: config.model_name || "",
api_key: (config as ImageGenerationConfig).api_key || "",
api_base: config.api_base || "",
api_version: config.api_version || "",
});
} else if (mode === "create") {
setFormData(INITIAL_FORM);
}
setScrollPos("top");
}
}, [open, mode, config, isGlobal]);
const { mutateAsync: createConfig } = useAtomValue(createImageGenConfigMutationAtom);
const { mutateAsync: updateConfig } = useAtomValue(updateImageGenConfigMutationAtom);
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const atTop = el.scrollTop <= 2;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
}, []);
const suggestedModels = useMemo(() => {
if (!formData.provider) return [];
return IMAGE_GEN_MODELS.filter((m) => m.provider === formData.provider);
}, [formData.provider]);
const getTitle = () => {
if (mode === "create") return "Add Image Model";
if (isGlobal) return "View Global Image Model";
return "Edit Image Model";
};
const getSubtitle = () => {
if (mode === "create") return "Set up a new image generation provider";
if (isGlobal) return "Read-only global configuration";
return "Update your image model settings";
};
const handleSubmit = useCallback(async () => {
setIsSubmitting(true);
try {
if (mode === "create") {
const result = await createConfig({
name: formData.name,
provider: formData.provider as ImageGenProvider,
model_name: formData.model_name,
api_key: formData.api_key,
api_base: formData.api_base || undefined,
api_version: formData.api_version || undefined,
description: formData.description || undefined,
search_space_id: searchSpaceId,
});
if (result?.id) {
await updatePreferences({
search_space_id: searchSpaceId,
data: { image_generation_config_id: result.id },
});
}
onOpenChange(false);
} else if (!isGlobal && config) {
await updateConfig({
id: config.id,
data: {
name: formData.name,
description: formData.description || undefined,
provider: formData.provider as ImageGenProvider,
model_name: formData.model_name,
api_key: formData.api_key,
api_base: formData.api_base || undefined,
api_version: formData.api_version || undefined,
},
});
onOpenChange(false);
}
} catch (error) {
console.error("Failed to save image config:", error);
toast.error("Failed to save image model");
} finally {
setIsSubmitting(false);
}
}, [
mode,
isGlobal,
config,
formData,
searchSpaceId,
createConfig,
updateConfig,
updatePreferences,
onOpenChange,
]);
const handleUseGlobalConfig = useCallback(async () => {
if (!config || !isGlobal) return;
setIsSubmitting(true);
try {
await updatePreferences({
search_space_id: searchSpaceId,
data: { image_generation_config_id: config.id },
});
toast.success(`Now using ${config.name}`);
onOpenChange(false);
} catch (error) {
console.error("Failed to set image model:", error);
toast.error("Failed to set image model");
} finally {
setIsSubmitting(false);
}
}, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]);
const isFormValid = formData.name && formData.provider && formData.model_name && formData.api_key;
const selectedProvider = IMAGE_GEN_PROVIDERS.find((p) => p.value === formData.provider);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-lg h-[85vh] flex flex-col p-0 gap-0 overflow-hidden"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<DialogTitle className="sr-only">{getTitle()}</DialogTitle>
{/* Header */}
<div className="flex items-start justify-between px-6 pt-6 pb-4 pr-14">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold tracking-tight">{getTitle()}</h2>
{isGlobal && mode !== "create" && (
<Badge variant="secondary" className="text-[10px]">
Global
</Badge>
)}
</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>
)}
</div>
</div>
{/* Scrollable content */}
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto px-6 py-5"
style={{
maskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
}}
>
{isGlobal && config && (
<>
<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">
Global configurations are read-only. To customize, create a new model.
</AlertDescription>
</Alert>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Name
</div>
<p className="text-sm font-medium">{config.name}</p>
</div>
{config.description && (
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Description
</div>
<p className="text-sm text-muted-foreground">{config.description}</p>
</div>
)}
</div>
<Separator />
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Provider
</div>
<p className="text-sm font-medium">{config.provider}</p>
</div>
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Model
</div>
<p className="text-sm font-medium font-mono">{config.model_name}</p>
</div>
</div>
</div>
</>
)}
{(mode === "create" || (mode === "edit" && !isGlobal)) && (
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-sm font-medium">Name *</Label>
<Input
placeholder="e.g., My DALL-E 3"
value={formData.name}
onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Description</Label>
<Input
placeholder="Optional description"
value={formData.description}
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
/>
</div>
<Separator />
<div className="space-y-2">
<Label className="text-sm font-medium">Provider *</Label>
<Select
value={formData.provider}
onValueChange={(val) =>
setFormData((p) => ({ ...p, provider: val, model_name: "" }))
}
>
<SelectTrigger>
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
{IMAGE_GEN_PROVIDERS.map((p) => (
<SelectItem key={p.value} value={p.value} description={p.example}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Model Name *</Label>
{suggestedModels.length > 0 ? (
<Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between font-normal bg-transparent"
>
{formData.model_name || "Select or type a model..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<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 }))}
/>
<CommandList>
<CommandEmpty>
<span className="text-xs text-muted-foreground">
Type a custom model name
</span>
</CommandEmpty>
<CommandGroup>
{suggestedModels.map((m) => (
<CommandItem
key={m.value}
value={m.value}
onSelect={() => {
setFormData((p) => ({ ...p, model_name: m.value }));
setModelComboboxOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.model_name === m.value ? "opacity-100" : "opacity-0"
)}
/>
<span className="font-mono text-sm">{m.value}</span>
<span className="ml-2 text-xs text-muted-foreground">
{m.label}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input
placeholder="e.g., dall-e-3"
value={formData.model_name}
onChange={(e) => setFormData((p) => ({ ...p, model_name: e.target.value }))}
/>
)}
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">API Key *</Label>
<Input
type="password"
placeholder="sk-..."
value={formData.api_key}
onChange={(e) => setFormData((p) => ({ ...p, api_key: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">API Base URL</Label>
<Input
placeholder={selectedProvider?.apiBase || "Optional"}
value={formData.api_base}
onChange={(e) => setFormData((p) => ({ ...p, api_base: e.target.value }))}
/>
</div>
{formData.provider === "AZURE_OPENAI" && (
<div className="space-y-2">
<Label className="text-sm font-medium">API Version (Azure)</Label>
<Input
placeholder="2024-02-15-preview"
value={formData.api_version}
onChange={(e) => setFormData((p) => ({ ...p, api_version: e.target.value }))}
/>
</div>
)}
</div>
)}
</div>
{/* Fixed footer */}
<div className="shrink-0 px-6 py-4 flex items-center justify-end gap-3">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
className="text-sm h-9"
>
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>
) : null}
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -3,9 +3,8 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { Check, ChevronDown, ChevronsUpDown } from "lucide-react"; import { Check, ChevronDown, ChevronsUpDown } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { type Resolver, useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { import {
defaultSystemInstructionsAtom, defaultSystemInstructionsAtom,
@ -41,7 +40,6 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { LLM_PROVIDERS } from "@/contracts/enums/llm-providers"; import { LLM_PROVIDERS } from "@/contracts/enums/llm-providers";
@ -73,28 +71,18 @@ interface LLMConfigFormProps {
initialData?: Partial<LLMConfigFormData>; initialData?: Partial<LLMConfigFormData>;
searchSpaceId: number; searchSpaceId: number;
onSubmit: (data: LLMConfigFormData) => Promise<void>; onSubmit: (data: LLMConfigFormData) => Promise<void>;
onCancel?: () => void;
isSubmitting?: boolean;
mode?: "create" | "edit"; mode?: "create" | "edit";
submitLabel?: string;
showAdvanced?: boolean; showAdvanced?: boolean;
compact?: boolean;
formId?: string; formId?: string;
hideActions?: boolean;
} }
export function LLMConfigForm({ export function LLMConfigForm({
initialData, initialData,
searchSpaceId, searchSpaceId,
onSubmit, onSubmit,
onCancel,
isSubmitting = false,
mode = "create", mode = "create",
submitLabel,
showAdvanced = true, showAdvanced = true,
compact = false,
formId, formId,
hideActions = false,
}: LLMConfigFormProps) { }: LLMConfigFormProps) {
const { data: defaultInstructions, isSuccess: defaultInstructionsLoaded } = useAtomValue( const { data: defaultInstructions, isSuccess: defaultInstructionsLoaded } = useAtomValue(
defaultSystemInstructionsAtom defaultSystemInstructionsAtom
@ -105,8 +93,7 @@ export function LLMConfigForm({
const [systemInstructionsOpen, setSystemInstructionsOpen] = useState(false); const [systemInstructionsOpen, setSystemInstructionsOpen] = useState(false);
const form = useForm<FormValues>({ const form = useForm<FormValues>({
// eslint-disable-next-line @typescript-eslint/no-explicit-any resolver: zodResolver(formSchema) as Resolver<FormValues>,
resolver: zodResolver(formSchema) as any,
defaultValues: { defaultValues: {
name: initialData?.name ?? "", name: initialData?.name ?? "",
description: initialData?.description ?? "", description: initialData?.description ?? "",
@ -233,33 +220,21 @@ export function LLMConfigForm({
/> />
{/* Custom Provider (conditional) */} {/* Custom Provider (conditional) */}
<AnimatePresence> {watchProvider === "CUSTOM" && (
{watchProvider === "CUSTOM" && ( <FormField
<motion.div control={form.control}
initial={{ opacity: 0, height: 0 }} name="custom_provider"
animate={{ opacity: 1, height: "auto" }} render={({ field }) => (
exit={{ opacity: 0, height: 0 }} <FormItem>
> <FormLabel className="text-xs sm:text-sm">Custom Provider Name</FormLabel>
<FormField <FormControl>
control={form.control} <Input placeholder="my-custom-provider" {...field} value={field.value ?? ""} />
name="custom_provider" </FormControl>
render={({ field }) => ( <FormMessage />
<FormItem> </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>
)}
/>
</motion.div>
)}
</AnimatePresence>
{/* Model Name with Combobox */} {/* Model Name with Combobox */}
<FormField <FormField
@ -405,35 +380,28 @@ export function LLMConfigForm({
</div> </div>
{/* Ollama Quick Actions */} {/* Ollama Quick Actions */}
<AnimatePresence> {watchProvider === "OLLAMA" && (
{watchProvider === "OLLAMA" && ( <div className="flex flex-wrap gap-2">
<motion.div <Button
initial={{ opacity: 0, height: 0 }} type="button"
animate={{ opacity: 1, height: "auto" }} variant="outline"
exit={{ opacity: 0, height: 0 }} size="sm"
className="flex flex-wrap gap-2" className="h-7 text-xs"
onClick={() => form.setValue("api_base", "http://localhost:11434")}
> >
<Button localhost:11434
type="button" </Button>
variant="outline" <Button
size="sm" type="button"
className="h-7 text-xs" variant="outline"
onClick={() => form.setValue("api_base", "http://localhost:11434")} size="sm"
> className="h-7 text-xs"
localhost:11434 onClick={() => form.setValue("api_base", "http://host.docker.internal:11434")}
</Button> >
<Button Docker
type="button" </Button>
variant="outline" </div>
size="sm" )}
className="h-7 text-xs"
onClick={() => form.setValue("api_base", "http://host.docker.internal:11434")}
>
Docker
</Button>
</motion.div>
)}
</AnimatePresence>
</div> </div>
{/* Advanced Parameters */} {/* Advanced Parameters */}
@ -554,44 +522,6 @@ export function LLMConfigForm({
/> />
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
{!hideActions && (
<div
className={cn(
"flex gap-3 pt-4",
compact ? "justify-end" : "justify-center sm:justify-end"
)}
>
{onCancel && (
<Button
type="button"
variant="secondary"
onClick={onCancel}
disabled={isSubmitting}
className="text-xs sm:text-sm h-9 sm:h-10"
>
Cancel
</Button>
)}
<Button
type="submit"
disabled={isSubmitting}
className="gap-2 min-w-[140px] sm:min-w-[160px] text-xs sm:text-sm h-9 sm:h-10"
>
{isSubmitting ? (
<>
<Spinner size="sm" />
{mode === "edit" ? "Updating..." : "Creating"}
</>
) : (
<>
{submitLabel ??
(mode === "edit" ? "Update Configuration" : "Create Configuration")}
</>
)}
</Button>
</div>
)}
</form> </form>
</Form> </Form>
); );

View file

@ -0,0 +1,333 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertCircle } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { toast } from "sonner";
import {
createNewLLMConfigMutationAtom,
updateLLMPreferencesMutationAtom,
updateNewLLMConfigMutationAtom,
} from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner";
import type {
GlobalNewLLMConfig,
LiteLLMProvider,
NewLLMConfigPublic,
} from "@/contracts/types/new-llm-config.types";
interface ModelConfigDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
config: NewLLMConfigPublic | GlobalNewLLMConfig | null;
isGlobal: boolean;
searchSpaceId: number;
mode: "create" | "edit" | "view";
}
export function ModelConfigDialog({
open,
onOpenChange,
config,
isGlobal,
searchSpaceId,
mode,
}: ModelConfigDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
const scrollRef = useRef<HTMLDivElement>(null);
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const atTop = el.scrollTop <= 2;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
}, []);
const { mutateAsync: createConfig } = useAtomValue(createNewLLMConfigMutationAtom);
const { mutateAsync: updateConfig } = useAtomValue(updateNewLLMConfigMutationAtom);
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
const getTitle = () => {
if (mode === "create") return "Add New Configuration";
if (isGlobal) return "View Global Configuration";
return "Edit Configuration";
};
const getSubtitle = () => {
if (mode === "create") return "Set up a new LLM provider for this search space";
if (isGlobal) return "Read-only global configuration";
return "Update your configuration settings";
};
const handleSubmit = useCallback(
async (data: LLMConfigFormData) => {
setIsSubmitting(true);
try {
if (mode === "create") {
const result = await createConfig({
...data,
search_space_id: searchSpaceId,
});
if (result?.id) {
await updatePreferences({
search_space_id: searchSpaceId,
data: {
agent_llm_id: result.id,
},
});
}
onOpenChange(false);
} else if (!isGlobal && config) {
await updateConfig({
id: config.id,
data: {
name: data.name,
description: data.description,
provider: data.provider,
custom_provider: data.custom_provider,
model_name: data.model_name,
api_key: data.api_key,
api_base: data.api_base,
litellm_params: data.litellm_params,
system_instructions: data.system_instructions,
use_default_system_instructions: data.use_default_system_instructions,
citations_enabled: data.citations_enabled,
},
});
onOpenChange(false);
}
} catch (error) {
console.error("Failed to save configuration:", error);
} finally {
setIsSubmitting(false);
}
},
[
mode,
isGlobal,
config,
searchSpaceId,
createConfig,
updateConfig,
updatePreferences,
onOpenChange,
]
);
const handleUseGlobalConfig = useCallback(async () => {
if (!config || !isGlobal) return;
setIsSubmitting(true);
try {
await updatePreferences({
search_space_id: searchSpaceId,
data: {
agent_llm_id: config.id,
},
});
toast.success(`Now using ${config.name}`);
onOpenChange(false);
} catch (error) {
console.error("Failed to set model:", error);
} finally {
setIsSubmitting(false);
}
}, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-lg h-[85vh] flex flex-col p-0 gap-0 overflow-hidden"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<DialogTitle className="sr-only">{getTitle()}</DialogTitle>
{/* Header */}
<div className="flex items-start justify-between px-6 pt-6 pb-4 pr-14">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold tracking-tight">{getTitle()}</h2>
{isGlobal && mode !== "create" && (
<Badge variant="secondary" className="text-[10px]">
Global
</Badge>
)}
{!isGlobal && mode !== "create" && (
<Badge variant="outline" className="text-[10px]">
Custom
</Badge>
)}
</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>
)}
</div>
</div>
{/* Scrollable content */}
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto px-6 py-5"
style={{
maskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
}}
>
{isGlobal && mode !== "create" && (
<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">
Global configurations are read-only. To customize settings, create a new
configuration based on this template.
</AlertDescription>
</Alert>
)}
{mode === "create" ? (
<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">
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Configuration Name
</div>
<p className="text-sm font-medium">{config.name}</p>
</div>
{config.description && (
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Description
</div>
<p className="text-sm text-muted-foreground">{config.description}</p>
</div>
)}
</div>
<div className="h-px bg-border/50" />
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Provider
</div>
<p className="text-sm font-medium">{config.provider}</p>
</div>
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Model
</div>
<p className="text-sm font-medium font-mono">{config.model_name}</p>
</div>
</div>
<div className="h-px bg-border/50" />
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Citations
</div>
<Badge
variant={config.citations_enabled ? "default" : "secondary"}
className="w-fit"
>
{config.citations_enabled ? "Enabled" : "Disabled"}
</Badge>
</div>
</div>
{config.system_instructions && (
<>
<div className="h-px bg-border/50" />
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
System Instructions
</div>
<div className="p-3 rounded-lg bg-muted/50 border border-border/50">
<p className="text-xs font-mono text-muted-foreground whitespace-pre-wrap line-clamp-10">
{config.system_instructions}
</p>
</div>
</div>
</>
)}
</div>
</div>
) : config ? (
<LLMConfigForm
searchSpaceId={searchSpaceId}
initialData={{
name: config.name,
description: config.description,
provider: config.provider as LiteLLMProvider,
custom_provider: config.custom_provider,
model_name: config.model_name,
api_key: "api_key" in config ? (config.api_key as string) : "",
api_base: config.api_base,
litellm_params: config.litellm_params,
system_instructions: config.system_instructions,
use_default_system_instructions: config.use_default_system_instructions,
citations_enabled: config.citations_enabled,
search_space_id: searchSpaceId,
}}
onSubmit={handleSubmit}
mode="edit"
formId="model-config-form"
/>
) : null}
</div>
{/* Fixed footer */}
<div className="shrink-0 px-6 py-4 flex items-center justify-end gap-3">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
className="text-sm h-9"
>
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>
) : null}
</div>
</DialogContent>
</Dialog>
);
}

View file

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

View file

@ -0,0 +1,395 @@
"use client";
import type { LucideIcon } 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 { CitationType, CitationVariant, SerializableCitation } from "./schema";
const TYPE_ICONS: Record<CitationType, LucideIcon> = {
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 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 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]
);
React.useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);
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;
}
export function CitationList(props: CitationListProps) {
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 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}
/>
);
}
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>
);
}
interface OverflowIndicatorProps {
citations: SerializableCitation[];
count: number;
variant: CitationVariant;
onNavigate?: (href: string, citation: SerializableCitation) => void;
}
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 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>
);
}
// 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;
}
function OverflowItem({ citation, onClick }: OverflowItemProps) {
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>
);
}
interface StackedCitationsProps {
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);
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>
);
}

View file

@ -0,0 +1,248 @@
"use client";
import type { LucideIcon } from "lucide-react";
import { Code2, Database, ExternalLink, File, FileText, Globe, Newspaper } from "lucide-react";
import * as React from "react";
import { openSafeNavigationHref, sanitizeHref } from "../shared/media";
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,
};
function extractDomain(url: string): string | 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;
}
}
function useHoverPopover(delay = 100) {
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 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);
};
}, []);
return { open, setOpen, handleMouseEnter, handleMouseLeave };
}
export interface CitationProps extends SerializableCitation {
variant?: CitationVariant;
className?: string;
onNavigate?: (href: string, citation: SerializableCitation) => void;
}
export function Citation(props: CitationProps) {
const { variant = "default", className, onNavigate, ...serializable } = props;
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 citationData: SerializableCitation = {
...serializable,
href: sanitizedHref ?? rawHref,
domain,
locale,
};
const TypeIcon = TYPE_ICONS[type] ?? Globe;
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 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();
// 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>
<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>
);
}

View file

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

View file

@ -0,0 +1,34 @@
import { z } from "zod";
import { ToolUIIdSchema, ToolUIReceiptSchema, ToolUIRoleSchema } from "../shared/schema";
export const CitationTypeSchema = z.enum([
"webpage",
"document",
"article",
"api",
"code",
"other",
]);
export type CitationType = z.infer<typeof CitationTypeSchema>;
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(),
});
export type SerializableCitation = z.infer<typeof SerializableCitationSchema>;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,28 @@
export function sanitizeHref(href?: string): string | 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;
}
try {
const url = new URL(candidate);
if (url.protocol === "http:" || url.protocol === "https:") {
return url.toString();
}
} catch {
return undefined;
}
return undefined;
}

View file

@ -0,0 +1,149 @@
import type { ReactNode } from "react";
import { z } from "zod";
/**
* Tool UI conventions:
* - Serializable schemas are JSON-safe (no callbacks/ReactNode/`className`).
* - Schema: `SerializableXSchema`
* - Parser: `parseSerializableX(input: unknown)` (throws on invalid)
* - Safe parser: `safeParseSerializableX(input: unknown)` (returns `null` on invalid)
* - Actions: `LocalActions` for non-receipt actions and `DecisionActions` for consequential actions
* - Root attrs: `data-tool-ui-id` + `data-slot`
*/
/**
* Schema for tool UI identity.
*
* Every tool UI should have a unique identifier that:
* - Is stable across re-renders
* - Is meaningful (not auto-generated)
* - Is unique within the conversation
*
* Format recommendation: `{component-type}-{semantic-identifier}`
* Examples: "data-table-expenses-q3", "option-list-deploy-target"
*/
export const ToolUIIdSchema = z.string().min(1);
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",
]);
export type ToolUIRole = z.infer<typeof ToolUIRoleSchema>;
export const ToolUIReceiptOutcomeSchema = z.enum(["success", "partial", "failed", "cancelled"]);
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(),
});
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(),
});
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(),
});
export type Action = z.infer<typeof ActionSchema>;
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(),
});
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>,
>(args: {
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,
};
}
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(),
});
export const SerializableActionSchema = ActionSchema.omit({ icon: true });
export const SerializableActionsSchema = ActionButtonsPropsSchema.extend({
actions: z.array(SerializableActionSchema),
}).omit({ className: true });
export interface ActionsConfig {
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(),
});
export type SerializableActionsConfig = z.infer<typeof SerializableActionsConfigSchema>;
export type SerializableAction = z.infer<typeof SerializableActionSchema>;

View file

@ -93,7 +93,7 @@ DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
function DrawerHandle({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) { function DrawerHandle({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return ( return (
<div <div
className={cn("mx-auto mt-4 h-1.5 w-12 rounded-full bg-muted-foreground/40", className)} className={cn("mx-auto mt-4 h-2 w-12 rounded-full bg-muted-foreground/40", className)}
{...props} {...props}
/> />
); );

View file

@ -31,6 +31,7 @@ export const CONNECTOR_TOOL_ICON_PATHS: Record<string, { src: string; alt: strin
gmail: { src: "/connectors/google-gmail.svg", alt: "Gmail" }, gmail: { src: "/connectors/google-gmail.svg", alt: "Gmail" },
google_calendar: { src: "/connectors/google-calendar.svg", alt: "Google Calendar" }, google_calendar: { src: "/connectors/google-calendar.svg", alt: "Google Calendar" },
google_drive: { src: "/connectors/google-drive.svg", alt: "Google Drive" }, google_drive: { src: "/connectors/google-drive.svg", alt: "Google Drive" },
onedrive: { src: "/connectors/onedrive.svg", alt: "OneDrive" },
notion: { src: "/connectors/notion.svg", alt: "Notion" }, notion: { src: "/connectors/notion.svg", alt: "Notion" },
linear: { src: "/connectors/linear.svg", alt: "Linear" }, linear: { src: "/connectors/linear.svg", alt: "Linear" },
jira: { src: "/connectors/jira.svg", alt: "Jira" }, jira: { src: "/connectors/jira.svg", alt: "Jira" },
@ -41,6 +42,7 @@ export const CONNECTOR_ICON_TO_TYPES: Record<string, string[]> = {
gmail: ["GOOGLE_GMAIL_CONNECTOR", "COMPOSIO_GMAIL_CONNECTOR"], gmail: ["GOOGLE_GMAIL_CONNECTOR", "COMPOSIO_GMAIL_CONNECTOR"],
google_calendar: ["GOOGLE_CALENDAR_CONNECTOR", "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR"], google_calendar: ["GOOGLE_CALENDAR_CONNECTOR", "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR"],
google_drive: ["GOOGLE_DRIVE_CONNECTOR", "COMPOSIO_GOOGLE_DRIVE_CONNECTOR"], google_drive: ["GOOGLE_DRIVE_CONNECTOR", "COMPOSIO_GOOGLE_DRIVE_CONNECTOR"],
onedrive: ["ONEDRIVE_CONNECTOR"],
notion: ["NOTION_CONNECTOR"], notion: ["NOTION_CONNECTOR"],
linear: ["LINEAR_CONNECTOR"], linear: ["LINEAR_CONNECTOR"],
jira: ["JIRA_CONNECTOR"], jira: ["JIRA_CONNECTOR"],

View file

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

View file

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

View file

@ -640,7 +640,6 @@
"active": "Active", "active": "Active",
"your_configs": "Your Configurations", "your_configs": "Your Configurations",
"manage_configs": "Manage and configure your LLM providers", "manage_configs": "Manage and configure your LLM providers",
"add_config": "Add Configuration",
"no_configs": "No Configurations Yet", "no_configs": "No Configurations Yet",
"no_configs_desc": "Add your own LLM provider configurations.", "no_configs_desc": "Add your own LLM provider configurations.",
"add_first_config": "Add First Configuration", "add_first_config": "Add First Configuration",

View file

@ -640,7 +640,6 @@
"active": "Activo", "active": "Activo",
"your_configs": "Tus configuraciones", "your_configs": "Tus configuraciones",
"manage_configs": "Administra y configura tus proveedores de LLM", "manage_configs": "Administra y configura tus proveedores de LLM",
"add_config": "Agregar configuración",
"no_configs": "Aún no hay configuraciones", "no_configs": "Aún no hay configuraciones",
"no_configs_desc": "Agrega tus propias configuraciones de proveedor de LLM.", "no_configs_desc": "Agrega tus propias configuraciones de proveedor de LLM.",
"add_first_config": "Agregar primera configuración", "add_first_config": "Agregar primera configuración",

View file

@ -640,7 +640,6 @@
"active": "सक्रिय", "active": "सक्रिय",
"your_configs": "आपकी कॉन्फ़िगरेशन", "your_configs": "आपकी कॉन्फ़िगरेशन",
"manage_configs": "अपने LLM प्रदाता प्रबंधित और कॉन्फ़िगर करें", "manage_configs": "अपने LLM प्रदाता प्रबंधित और कॉन्फ़िगर करें",
"add_config": "कॉन्फ़िगरेशन जोड़ें",
"no_configs": "अभी तक कोई कॉन्फ़िगरेशन नहीं", "no_configs": "अभी तक कोई कॉन्फ़िगरेशन नहीं",
"no_configs_desc": "अपनी LLM प्रदाता कॉन्फ़िगरेशन जोड़ें।", "no_configs_desc": "अपनी LLM प्रदाता कॉन्फ़िगरेशन जोड़ें।",
"add_first_config": "पहली कॉन्फ़िगरेशन जोड़ें", "add_first_config": "पहली कॉन्फ़िगरेशन जोड़ें",

View file

@ -640,7 +640,6 @@
"active": "Ativo", "active": "Ativo",
"your_configs": "Suas configurações", "your_configs": "Suas configurações",
"manage_configs": "Gerencie e configure seus provedores de LLM", "manage_configs": "Gerencie e configure seus provedores de LLM",
"add_config": "Adicionar configuração",
"no_configs": "Nenhuma configuração ainda", "no_configs": "Nenhuma configuração ainda",
"no_configs_desc": "Adicione suas próprias configurações de provedor de LLM.", "no_configs_desc": "Adicione suas próprias configurações de provedor de LLM.",
"add_first_config": "Adicionar primeira configuração", "add_first_config": "Adicionar primeira configuração",

View file

@ -624,7 +624,6 @@
"active": "活跃", "active": "活跃",
"your_configs": "您的配置", "your_configs": "您的配置",
"manage_configs": "管理和配置您的 LLM 提供商", "manage_configs": "管理和配置您的 LLM 提供商",
"add_config": "添加配置",
"no_configs": "暂无配置", "no_configs": "暂无配置",
"no_configs_desc": "添加您自己的 LLM 提供商配置。", "no_configs_desc": "添加您自己的 LLM 提供商配置。",
"add_first_config": "添加首个配置", "add_first_config": "添加首个配置",