Merge upstream/dev

This commit is contained in:
CREDO23 2026-03-31 20:21:12 +02:00
commit 440762fb07
92 changed files with 3227 additions and 2502 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1075,6 +1075,37 @@ async def _stream_agent_events(
"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:
yield streaming_service.format_tool_output_available(
tool_call_id,

View file

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

View file

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

View file

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

View file

@ -96,7 +96,7 @@ export function LocalLoginForm() {
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.95 }}
transition={{ duration: 0.3 }}
className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-900 shadow-sm dark:border-red-900/30 dark:bg-red-900/20 dark:text-red-200"
className="rounded-lg border border-destructive/20 bg-destructive/10 p-4 text-destructive shadow-sm"
>
<div className="flex items-start gap-3">
<svg
@ -109,7 +109,7 @@ export function LocalLoginForm() {
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="flex-shrink-0 mt-0.5 text-red-500 dark:text-red-400"
className="flex-shrink-0 mt-0.5 text-destructive"
>
<title>Error Icon</title>
<circle cx="12" cy="12" r="10" />
@ -118,13 +118,13 @@ export function LocalLoginForm() {
</svg>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold mb-1">{error.title}</p>
<p className="text-sm text-red-700 dark:text-red-300">{error.message}</p>
<p className="text-sm text-destructive">{error.message}</p>
</div>
<button
onClick={() => {
setError({ title: null, message: null });
}}
className="flex-shrink-0 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 transition-colors"
className="flex-shrink-0 text-destructive hover:text-destructive/90 transition-colors"
aria-label="Dismiss error"
type="button"
>
@ -152,7 +152,7 @@ export function LocalLoginForm() {
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
className="block text-sm font-medium text-foreground"
>
{t("email")}
</label>
@ -163,10 +163,10 @@ export function LocalLoginForm() {
placeholder="you@example.com"
value={username}
onChange={(e) => setUsername(e.target.value)}
className={`mt-1 block w-full rounded-md border px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-all ${
error.title
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
className={`mt-1 block w-full rounded-md border px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 bg-background text-foreground transition-all ${
error.title
? "border-destructive focus:border-destructive focus:ring-destructive"
: "border-border focus:border-primary focus:ring-primary"
}`}
disabled={isLoggingIn}
/>
@ -175,7 +175,7 @@ export function LocalLoginForm() {
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
className="block text-sm font-medium text-foreground"
>
{t("password")}
</label>
@ -187,17 +187,17 @@ export function LocalLoginForm() {
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={`mt-1 block w-full rounded-md border pr-10 px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-all ${
className={`mt-1 block w-full rounded-md border pr-10 px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 bg-background text-foreground transition-all ${
error.title
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
? "border-destructive focus:border-destructive focus:ring-destructive"
: "border-border focus:border-primary focus:ring-primary"
}`}
disabled={isLoggingIn}
/>
<button
type="button"
onClick={() => setShowPassword((prev) => !prev)}
className="absolute inset-y-0 right-0 flex items-center pr-3 mt-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
className="absolute inset-y-0 right-0 flex items-center pr-3 mt-1 text-muted-foreground hover:text-foreground"
aria-label={showPassword ? t("hide_password") : t("show_password")}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
@ -208,12 +208,12 @@ export function LocalLoginForm() {
<button
type="submit"
disabled={isLoggingIn}
className="relative w-full rounded-md bg-blue-600 px-4 py-1.5 md:py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-all text-sm md:text-base flex items-center justify-center gap-2"
className="relative w-full rounded-md bg-primary px-4 py-1.5 md:py-2 text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-all text-sm md:text-base flex items-center justify-center gap-2"
>
<span className={isLoggingIn ? "invisible" : ""}>{t("sign_in")}</span>
{isLoggingIn && (
<span className="absolute inset-0 flex items-center justify-center">
<Spinner size="sm" className="text-white" />
<Spinner size="sm" className="text-primary-foreground" />
</span>
)}
</button>
@ -221,11 +221,11 @@ export function LocalLoginForm() {
{authType === "LOCAL" && (
<div className="mt-4 text-center text-sm">
<p className="text-gray-600 dark:text-gray-400">
<p className="text-muted-foreground">
{t("dont_have_account")}{" "}
<Link
href="/register"
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
className="font-medium text-primary hover:text-primary/90"
>
{t("sign_up")}
</Link>

View file

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

View file

@ -990,9 +990,10 @@ export function DocumentsTableShell({
handleDeleteFromMenu();
}}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? <Spinner size="sm" /> : "Delete"}
<span className={isDeleting ? "opacity-0" : ""}>Delete</span>
{isDeleting && <Spinner size="sm" className="absolute" />}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@ -1108,9 +1109,10 @@ export function DocumentsTableShell({
handleBulkDelete();
}}
disabled={isBulkDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isBulkDeleting ? <Spinner size="sm" /> : "Delete"}
<span className={isBulkDeleting ? "opacity-0" : ""}>Delete</span>
{isBulkDeleting && <Spinner size="sm" className="absolute" />}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View file

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

View file

@ -33,7 +33,7 @@ import { closeReportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { type AgentCreatedDocument, agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms";
import { closeEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { membersAtom } from "@/atoms/members/members-query.atoms";
import { updateChatTabTitleAtom } from "@/atoms/tabs/tabs.atom";
import { removeChatTabAtom, updateChatTabTitleAtom } from "@/atoms/tabs/tabs.atom";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { ThinkingStepsDataUI } from "@/components/assistant-ui/thinking-steps";
import { Thread } from "@/components/assistant-ui/thread";
@ -56,6 +56,7 @@ import {
buildContentForPersistence,
buildContentForUI,
type ContentPartsState,
FrameBatchedUpdater,
readSSEStream,
type ThinkingStepData,
updateThinkingSteps,
@ -69,6 +70,7 @@ import {
getThreadMessages,
type ThreadRecord,
} from "@/lib/chat/thread-persistence";
import { NotFoundError } from "@/lib/error";
import {
trackChatCreated,
trackChatError,
@ -130,6 +132,7 @@ function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] {
* Tools that should render custom UI in the chat.
*/
const TOOLS_WITH_UI = new Set([
"web_search",
"generate_podcast",
"generate_report",
"generate_video_presentation",
@ -193,6 +196,7 @@ export default function NewChatPage() {
const closeReportPanel = useSetAtom(closeReportPanelAtom);
const closeEditorPanel = useSetAtom(closeEditorPanelAtom);
const updateChatTabTitle = useSetAtom(updateChatTabTitleAtom);
const removeChatTab = useSetAtom(removeChatTabAtom);
const setAgentCreatedDocuments = useSetAtom(agentCreatedDocumentsAtom);
// Get current user for author info in shared chats
@ -272,7 +276,6 @@ export default function NewChatPage() {
// Initialize thread and load messages
// For new chats (no urlChatId), we use lazy creation - thread is created on first message
// biome-ignore lint/correctness/useExhaustiveDependencies: searchSpaceId triggers re-init when switching spaces with the same urlChatId
const initializeThread = useCallback(async () => {
setIsInitializing(true);
@ -323,6 +326,14 @@ export default function NewChatPage() {
// This improves UX (instant load) and avoids orphan threads
} catch (error) {
console.error("[NewChatPage] Failed to initialize thread:", error);
if (urlChatId > 0 && error instanceof NotFoundError) {
removeChatTab(urlChatId);
if (typeof window !== "undefined") {
window.history.replaceState(null, "", `/dashboard/${searchSpaceId}/new-chat`);
}
toast.error("This chat was deleted.");
return;
}
// Keep threadId as null - don't use Date.now() as it creates an invalid ID
// that will cause 404 errors on subsequent API calls
setThreadId(null);
@ -333,15 +344,16 @@ export default function NewChatPage() {
}
}, [
urlChatId,
searchSpaceId,
setMessageDocumentsMap,
setMentionedDocuments,
setSidebarDocuments,
closeReportPanel,
closeEditorPanel,
removeChatTab,
searchSpaceId,
]);
// Initialize on mount
// Initialize on mount, and re-init when switching search spaces (even if urlChatId is the same)
useEffect(() => {
initializeThread();
}, [initializeThread]);
@ -484,18 +496,17 @@ export default function NewChatPage() {
// Add user message to state
const userMsgId = `msg-user-${Date.now()}`;
// Include author metadata for shared chats
const authorMetadata =
currentThread?.visibility === "SEARCH_SPACE" && currentUser
? {
custom: {
author: {
displayName: currentUser.display_name ?? null,
avatarUrl: currentUser.avatar_url ?? null,
},
// Always include author metadata so the UI layer can decide visibility
const authorMetadata = currentUser
? {
custom: {
author: {
displayName: currentUser.display_name ?? null,
avatarUrl: currentUser.avatar_url ?? null,
},
}
: undefined;
},
}
: undefined;
const userMessage: ThreadMessageLike = {
id: userMsgId,
@ -571,6 +582,7 @@ export default function NewChatPage() {
// Prepare assistant message
const assistantMsgId = `msg-assistant-${Date.now()}`;
const currentThinkingSteps = new Map<string, ThinkingStepData>();
const batcher = new FrameBatchedUpdater();
const contentPartsState: ContentPartsState = {
contentParts: [],
@ -642,33 +654,30 @@ export default function NewChatPage() {
throw new Error(`Backend error: ${response.status}`);
}
const flushMessages = () => {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
: m
)
);
};
const scheduleFlush = () => batcher.schedule(flushMessages);
for await (const parsed of readSSEStream(response)) {
switch (parsed.type) {
case "text-delta":
appendText(contentPartsState, parsed.delta);
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
: m
)
);
scheduleFlush();
break;
case "tool-input-start":
// Add tool call inline - this breaks the current text segment
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
: m
)
);
batcher.flush();
break;
case "tool-input-available": {
// Update existing tool call's args, or add if not exists
if (toolCallIndices.has(parsed.toolCallId)) {
updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} });
} else {
@ -680,23 +689,14 @@ export default function NewChatPage() {
parsed.input || {}
);
}
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
: m
)
);
batcher.flush();
break;
}
case "tool-output-available": {
// Update the tool call with its result
updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output });
markInterruptsCompleted(contentParts);
// Handle podcast-specific logic
if (parsed.output?.status === "pending" && parsed.output?.podcast_id) {
// Check if this is a podcast tool by looking at the content part
const idx = toolCallIndices.get(parsed.toolCallId);
if (idx !== undefined) {
const part = contentParts[idx];
@ -705,13 +705,7 @@ export default function NewChatPage() {
}
}
}
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
: m
)
);
batcher.flush();
break;
}
@ -719,14 +713,10 @@ export default function NewChatPage() {
const stepData = parsed.data as ThinkingStepData;
if (stepData?.id) {
currentThinkingSteps.set(stepData.id, stepData);
updateThinkingSteps(contentPartsState, currentThinkingSteps);
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
: m
)
);
const didUpdate = updateThinkingSteps(contentPartsState, currentThinkingSteps);
if (didUpdate) {
scheduleFlush();
}
}
break;
}
@ -803,6 +793,8 @@ export default function NewChatPage() {
}
}
batcher.flush();
// Skip persistence for interrupted messages -- handleResume will persist the final version
const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI);
if (contentParts.length > 0 && !wasInterrupted) {
@ -832,6 +824,7 @@ export default function NewChatPage() {
trackChatResponseReceived(searchSpaceId, currentThreadId);
}
} catch (error) {
batcher.dispose();
if (error instanceof Error && error.name === "AbortError") {
// Request was cancelled by user - persist partial response if any content was received
const hasContent = contentParts.some(
@ -899,8 +892,8 @@ export default function NewChatPage() {
setMentionedDocuments,
setSidebarDocuments,
setMessageDocumentsMap,
setAgentCreatedDocuments,
queryClient,
currentThread,
currentUser,
disabledTools,
updateChatTabTitle,
@ -931,6 +924,7 @@ export default function NewChatPage() {
abortControllerRef.current = controller;
const currentThinkingSteps = new Map<string, ThinkingStepData>();
const batcher = new FrameBatchedUpdater();
const contentPartsState: ContentPartsState = {
contentParts: [],
@ -1018,28 +1012,27 @@ export default function NewChatPage() {
throw new Error(`Backend error: ${response.status}`);
}
const flushMessages = () => {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
: m
)
);
};
const scheduleFlush = () => batcher.schedule(flushMessages);
for await (const parsed of readSSEStream(response)) {
switch (parsed.type) {
case "text-delta":
appendText(contentPartsState, parsed.delta);
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
: m
)
);
scheduleFlush();
break;
case "tool-input-start":
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
: m
)
);
batcher.flush();
break;
case "tool-input-available":
@ -1056,13 +1049,7 @@ export default function NewChatPage() {
parsed.input || {}
);
}
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
: m
)
);
batcher.flush();
break;
case "tool-output-available":
@ -1070,27 +1057,17 @@ export default function NewChatPage() {
result: parsed.output,
});
markInterruptsCompleted(contentParts);
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
: m
)
);
batcher.flush();
break;
case "data-thinking-step": {
const stepData = parsed.data as ThinkingStepData;
if (stepData?.id) {
currentThinkingSteps.set(stepData.id, stepData);
updateThinkingSteps(contentPartsState, currentThinkingSteps);
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
: m
)
);
const didUpdate = updateThinkingSteps(contentPartsState, currentThinkingSteps);
if (didUpdate) {
scheduleFlush();
}
}
break;
}
@ -1144,6 +1121,8 @@ export default function NewChatPage() {
}
}
batcher.flush();
const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI);
if (contentParts.length > 0) {
try {
@ -1160,6 +1139,7 @@ export default function NewChatPage() {
}
}
} catch (error) {
batcher.dispose();
if (error instanceof Error && error.name === "AbortError") {
return;
}
@ -1305,6 +1285,7 @@ export default function NewChatPage() {
toolCallIndices: new Map(),
};
const { contentParts, toolCallIndices } = contentPartsState;
const batcher = new FrameBatchedUpdater();
// Add placeholder messages to UI
// Always add back the user message (with new query for edit, or original content for reload)
@ -1349,28 +1330,27 @@ export default function NewChatPage() {
throw new Error(`Backend error: ${response.status}`);
}
const flushMessages = () => {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
: m
)
);
};
const scheduleFlush = () => batcher.schedule(flushMessages);
for await (const parsed of readSSEStream(response)) {
switch (parsed.type) {
case "text-delta":
appendText(contentPartsState, parsed.delta);
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
: m
)
);
scheduleFlush();
break;
case "tool-input-start":
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
: m
)
);
batcher.flush();
break;
case "tool-input-available":
@ -1385,13 +1365,7 @@ export default function NewChatPage() {
parsed.input || {}
);
}
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
: m
)
);
batcher.flush();
break;
case "tool-output-available":
@ -1406,27 +1380,17 @@ export default function NewChatPage() {
}
}
}
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
: m
)
);
batcher.flush();
break;
case "data-thinking-step": {
const stepData = parsed.data as ThinkingStepData;
if (stepData?.id) {
currentThinkingSteps.set(stepData.id, stepData);
updateThinkingSteps(contentPartsState, currentThinkingSteps);
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
: m
)
);
const didUpdate = updateThinkingSteps(contentPartsState, currentThinkingSteps);
if (didUpdate) {
scheduleFlush();
}
}
break;
}
@ -1436,6 +1400,8 @@ export default function NewChatPage() {
}
}
batcher.flush();
// Persist messages after streaming completes
const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI);
if (contentParts.length > 0) {
@ -1477,6 +1443,7 @@ export default function NewChatPage() {
if (error instanceof Error && error.name === "AbortError") {
return;
}
batcher.dispose();
console.error("[NewChatPage] Regeneration error:", error);
trackChatError(
searchSpaceId,
@ -1484,7 +1451,6 @@ export default function NewChatPage() {
error instanceof Error ? error.message : "Unknown error"
);
toast.error("Failed to regenerate response. Please try again.");
// Update assistant message with error
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId

View file

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

View file

@ -229,8 +229,9 @@ export function PromptsContent() {
<Button variant="ghost" size="sm" onClick={handleCancel}>
Cancel
</Button>
<Button size="sm" onClick={handleSave} disabled={isSaving}>
{isSaving ? <Spinner className="size-3.5" /> : editingId !== null ? "Update" : "Create"}
<Button size="sm" onClick={handleSave} disabled={isSaving} className="relative">
<span className={isSaving ? "opacity-0" : ""}>{editingId !== null ? "Update" : "Create"}</span>
{isSaving && <Spinner className="size-3.5 absolute" />}
</Button>
</div>
</div>

View file

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

View file

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

View file

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

View file

@ -12,18 +12,25 @@ import {
ClipboardPaste,
CopyIcon,
DownloadIcon,
ExternalLink,
Globe,
MessageSquare,
RefreshCwIcon,
} from "lucide-react";
import type { FC } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { commentsEnabledAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import {
CitationMetadataProvider,
useAllCitationMetadata,
} from "@/components/assistant-ui/citation-metadata-context";
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container";
import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet";
import type { SerializableCitation } from "@/components/tool-ui/citation";
import {
CreateConfluencePageToolUI,
DeleteConfluencePageToolUI,
@ -64,12 +71,157 @@ import {
} from "@/components/tool-ui/notion";
import { CreateOneDriveFileToolUI, DeleteOneDriveFileToolUI } from "@/components/tool-ui/onedrive";
import { SandboxExecuteToolUI } from "@/components/tool-ui/sandbox-execute";
import {
openSafeNavigationHref,
resolveSafeNavigationHref,
} from "@/components/tool-ui/shared/media";
import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory";
import { GenerateVideoPresentationToolUI } from "@/components/tool-ui/video-presentation";
import { Drawer, DrawerContent, DrawerHandle, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
import { useComments } from "@/hooks/use-comments";
import { useMediaQuery } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils";
function extractDomain(url: string): string | undefined {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return undefined;
}
}
function useCitationsFromMetadata(): SerializableCitation[] {
const allCitations = useAllCitationMetadata();
return useMemo(() => {
const result: SerializableCitation[] = [];
for (const [url, meta] of allCitations) {
const domain = extractDomain(url);
result.push({
id: `url-cite-${url}`,
href: url,
title: meta.title,
snippet: meta.snippet,
domain,
favicon: domain ? `https://www.google.com/s2/favicons?domain=${domain}&sz=32` : undefined,
type: "webpage",
});
}
return result;
}, [allCitations]);
}
const MobileCitationDrawer: FC = () => {
const [open, setOpen] = useState(false);
const citations = useCitationsFromMetadata();
if (citations.length === 0) return null;
const maxIcons = 4;
const visible = citations.slice(0, maxIcons);
const remainingCount = Math.max(0, citations.length - maxIcons);
const handleNavigate = (citation: SerializableCitation) => {
const href = resolveSafeNavigationHref(citation.href);
if (href) openSafeNavigationHref(href);
};
return (
<>
<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 = () => {
return (
<MessagePrimitive.Error>
@ -81,8 +233,10 @@ export const MessageError: FC = () => {
};
const AssistantMessageInner: FC = () => {
const isMobile = !useMediaQuery("(min-width: 768px)");
return (
<>
<CitationMetadataProvider>
<div className="aui-assistant-message-content wrap-break-word px-2 text-foreground leading-relaxed">
<MessagePrimitive.Parts
components={{
@ -120,6 +274,7 @@ const AssistantMessageInner: FC = () => {
create_confluence_page: CreateConfluencePageToolUI,
update_confluence_page: UpdateConfluencePageToolUI,
delete_confluence_page: DeleteConfluencePageToolUI,
web_search: () => null,
link_preview: () => null,
multi_link_preview: () => null,
scrape_webpage: () => null,
@ -131,10 +286,16 @@ const AssistantMessageInner: FC = () => {
<MessageError />
</div>
{isMobile && (
<div className="ml-2 mt-2">
<MobileCitationDrawer />
</div>
)}
<div className="aui-assistant-message-footer mt-1 mb-5 ml-2 flex">
<AssistantActionBar />
</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";
import { useAtomValue, useSetAtom } from "jotai";
import { AlertTriangle, Cable, Settings } from "lucide-react";
import { AlertTriangle, Settings } from "lucide-react";
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
@ -12,17 +12,14 @@ import {
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { useConnectorsSync } from "@/hooks/use-connectors-sync";
import { PICKER_CLOSE_EVENT, PICKER_OPEN_EVENT } from "@/hooks/use-google-picker";
import { useZeroDocumentTypeCounts } from "@/hooks/use-zero-document-type-counts";
import { cn } from "@/lib/utils";
import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header";
import { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-view";
import { ConnectorEditView } from "./connector-popup/connector-configs/views/connector-edit-view";
@ -47,7 +44,7 @@ interface ConnectorIndicatorProps {
}
export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, ConnectorIndicatorProps>(
({ showTrigger = true }, ref) => {
(_props, ref) => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
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)
const documentTypeCounts = useZeroDocumentTypeCounts(searchSpaceId);
const documentTypesLoading = documentTypeCounts === undefined;
// Read status inbox items from shared atom (populated by LayoutDataProvider)
// instead of creating a duplicate useInbox("status") hook.
const statusInboxItems = useAtomValue(statusInboxItemsAtom);
@ -178,8 +173,6 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
inboxItems
);
const isLoading = connectorsLoading || documentTypesLoading;
// Get document types that have documents in the search space
const activeDocumentTypes = documentTypeCounts
? Object.entries(documentTypeCounts).filter(([, count]) => count > 0)
@ -205,41 +198,6 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
return (
<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 &&
createPortal(
<div

View file

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

View file

@ -243,7 +243,7 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowDetails(!showDetails);
setShowDetails(prev => !prev);
}}
>
{showDetails ? (

View file

@ -250,7 +250,7 @@ export const ComposioDriveConfig: FC<ConnectorConfigProps> = ({ connector, onCon
<div className="space-y-2">
<button
type="button"
onClick={() => setIsFolderTreeOpen(!isFolderTreeOpen)}
onClick={() => setIsFolderTreeOpen(prev => !prev)}
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground transition-colors w-fit"
>
Change Selection

View file

@ -248,7 +248,7 @@ export const MCPConfig: FC<MCPConfigProps> = ({ connector, onConfigChange, onNam
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowDetails(!showDetails);
setShowDetails(prev => !prev);
}}
>
{showDetails ? (

View file

@ -220,7 +220,7 @@ export const OneDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfigCh
<div className="space-y-2">
<button
type="button"
onClick={() => setIsFolderTreeOpen(!isFolderTreeOpen)}
onClick={() => setIsFolderTreeOpen(prev => !prev)}
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground transition-colors w-fit"
>
Change Selection

View file

@ -86,7 +86,7 @@ export const WebcrawlerConfig: FC<ConnectorConfigProps> = ({ connector, onConfig
type="button"
variant="ghost"
size="sm"
onClick={() => setShowApiKey(!showApiKey)}
onClick={() => setShowApiKey(prev => !prev)}
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
>
{showApiKey ? "Hide" : "Show"}

View file

@ -1,9 +1,10 @@
"use client";
import { ExternalLink } from "lucide-react";
import type { FC } from "react";
import { useState } from "react";
import { useCitationMetadata } from "@/components/assistant-ui/citation-metadata-context";
import { SourceDetailPanel } from "@/components/new-chat/source-detail-panel";
import { Citation } from "@/components/tool-ui/citation";
interface InlineCitationProps {
chunkId: number;
@ -55,21 +56,23 @@ interface UrlCitationProps {
/**
* Inline citation for live web search results (URL-based chunk IDs).
* Renders a clickable badge showing the source domain that opens the URL in a new tab.
* Renders a compact chip with favicon + domain and a hover popover showing the
* page title and snippet (extracted deterministically from web_search tool results).
*/
export const UrlCitation: FC<UrlCitationProps> = ({ url }) => {
const domain = extractDomain(url);
const meta = useCitationMetadata(url);
return (
<a
<Citation
id={`url-cite-${url}`}
href={url}
target="_blank"
rel="noopener noreferrer"
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"
title={url}
>
<ExternalLink className="size-2.5 shrink-0" />
{domain}
</a>
title={meta?.title || domain}
snippet={meta?.snippet}
domain={domain}
favicon={`https://www.google.com/s2/favicons?domain=${domain}&sz=32`}
variant="inline"
type="webpage"
/>
);
};

View file

@ -175,6 +175,7 @@ function parseTextWithCitations(text: string): ReactNode[] {
const MarkdownTextImpl = () => {
return (
<MarkdownTextPrimitive
smooth={false}
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
className="aui-md"

View file

@ -20,8 +20,6 @@ export const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?:
steps,
isThreadRunning = true,
}) => {
const [isOpen, setIsOpen] = useState(true);
const getEffectiveStatus = useCallback(
(step: ThinkingStep): "pending" | "in_progress" | "completed" => {
if (step.status === "in_progress" && !isThreadRunning) {
@ -38,12 +36,18 @@ export const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?:
!isThreadRunning &&
steps.every((s) => getEffectiveStatus(s) === "completed");
const isProcessing = isThreadRunning && !allCompleted;
const [isOpen, setIsOpen] = useState(() => isProcessing);
useEffect(() => {
if (isProcessing) {
setIsOpen(true);
return;
}
if (allCompleted) {
setIsOpen(false);
}
}, [allCompleted]);
}, [allCompleted, isProcessing]);
if (steps.length === 0) return null;
@ -65,7 +69,7 @@ export const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?:
<div className="rounded-lg">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
onClick={() => setIsOpen(prev => !prev)}
className={cn(
"flex w-full items-center gap-1.5 text-left text-sm transition-colors",
"text-muted-foreground hover:text-foreground"

View file

@ -9,7 +9,7 @@ import {
TrashIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { memo, useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@ -215,7 +215,7 @@ interface ThreadListItemComponentProps {
onDelete: () => void;
}
function ThreadListItemComponent({
const ThreadListItemComponent = memo(function ThreadListItemComponent({
thread,
isActive,
isArchived,
@ -272,7 +272,7 @@ function ThreadListItemComponent({
</DropdownMenu>
</button>
);
}
});
/**
* Format a date as relative time (e.g., "2 hours ago", "Yesterday")

View file

@ -5,6 +5,7 @@ import {
ThreadPrimitive,
useAui,
useAuiState,
useThreadViewportStore,
} from "@assistant-ui/react";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import {
@ -113,7 +114,8 @@ const ThreadContent: FC = () => {
>
<ThreadPrimitive.Viewport
turnAnchor="top"
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-scroll px-4 pt-4"
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4"
style={{ scrollbarGutter: "stable" }}
>
<AuiIf condition={({ thread }) => thread.isEmpty}>
<ThreadWelcome />
@ -128,7 +130,7 @@ const ThreadContent: FC = () => {
/>
<ThreadPrimitive.ViewportFooter
className="aui-thread-viewport-footer sticky bottom-0 z-10 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-main-panel pb-4 md:pb-6"
className="aui-thread-viewport-footer sticky bottom-0 z-10 mx-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-main-panel pb-4 md:pb-6"
style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }}
>
<ThreadScrollToBottom />
@ -349,7 +351,15 @@ const Composer: FC = () => {
const promptPickerRef = useRef<PromptPickerRef>(null);
const { search_space_id, chat_id } = useParams();
const aui = useAui();
const threadViewportStore = useThreadViewportStore();
const hasAutoFocusedRef = useRef(false);
const submitCleanupRef = useRef<(() => void) | null>(null);
useEffect(() => {
return () => {
submitCleanupRef.current?.();
};
}, []);
const [clipboardInitialText, setClipboardInitialText] = useState<string | undefined>();
const clipboardLoadedRef = useRef(false);
@ -593,6 +603,63 @@ const Composer: FC = () => {
setMentionedDocuments([]);
setSidebarDocs([]);
}
if (isThreadRunning || isBlockedByOtherUser) return;
if (showDocumentPopover) return;
const viewportEl = document.querySelector(".aui-thread-viewport");
const heightBefore = viewportEl?.scrollHeight ?? 0;
aui.composer().send();
editorRef.current?.clear();
setMentionedDocuments([]);
setSidebarDocs([]);
// With turnAnchor="top", ViewportSlack adds min-height to the last
// assistant message so that scrolling-to-bottom actually positions the
// user message at the TOP of the viewport. That slack height is
// calculated asynchronously (ResizeObserver → style → layout).
//
// We poll via rAF for ~2 s, re-scrolling whenever scrollHeight changes
// (user msg render → assistant placeholder → ViewportSlack min-height →
// first streamed content). Backup setTimeout calls cover cases where
// the batcher's 50 ms throttle delays the DOM update past the rAF.
const scrollToBottom = () =>
threadViewportStore.getState().scrollToBottom({ behavior: "instant" });
let lastHeight = heightBefore;
let frames = 0;
let cancelled = false;
const POLL_FRAMES = 120;
const pollAndScroll = () => {
if (cancelled) return;
const el = document.querySelector(".aui-thread-viewport");
if (el) {
const h = el.scrollHeight;
if (h !== lastHeight) {
lastHeight = h;
scrollToBottom();
}
}
if (++frames < POLL_FRAMES) {
requestAnimationFrame(pollAndScroll);
}
};
requestAnimationFrame(pollAndScroll);
const t1 = setTimeout(scrollToBottom, 100);
const t2 = setTimeout(scrollToBottom, 300);
const t3 = setTimeout(scrollToBottom, 600);
// Cleanup if component unmounts during the polling window. The ref is
// checked inside pollAndScroll; timeouts are cleared in the return below.
// Store cleanup fn so it can be called from a useEffect cleanup if needed.
submitCleanupRef.current = () => {
cancelled = true;
clearTimeout(t1);
clearTimeout(t2);
clearTimeout(t3);
};
}, [
showDocumentPopover,
showPromptPicker,
@ -602,6 +669,7 @@ const Composer: FC = () => {
aui,
setMentionedDocuments,
setSidebarDocs,
threadViewportStore,
]);
const handleDocumentRemove = useCallback(

View file

@ -45,7 +45,7 @@ export const ToolFallback: ToolCallMessagePartComponent = ({
>
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
onClick={() => setIsExpanded(prev => !prev)}
className="flex w-full items-center gap-3 px-5 py-4 text-left transition-colors hover:bg-muted/50 focus:outline-none focus-visible:outline-none"
>
<div

View file

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

View file

@ -93,7 +93,7 @@ export function CommentThread({
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={() => setIsRepliesExpanded(!isRepliesExpanded)}
onClick={() => setIsRepliesExpanded(prev => !prev)}
>
{isRepliesExpanded ? (
<ChevronDown className="mr-1 size-3" />

View file

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

View file

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

View file

@ -162,7 +162,7 @@ const MobileNav = ({ navItems, isScrolled, scrolledBgClassName }: any) => {
</Link>
<button
type="button"
onClick={() => setOpen(!open)}
onClick={() => setOpen(prev => !prev)}
className="relative z-50 flex items-center justify-center p-2 -mr-2 rounded-lg hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors touch-manipulation"
aria-label={open ? "Close menu" : "Open menu"}
>

View file

@ -37,8 +37,14 @@ export function useSidebarState(defaultCollapsed = false): UseSidebarStateReturn
}, []);
const toggleCollapsed = useCallback(() => {
setIsCollapsed(!isCollapsed);
}, [isCollapsed, setIsCollapsed]);
setIsCollapsedState(prev => {
const next = !prev;
try {
document.cookie = `${SIDEBAR_COOKIE_NAME}=${next}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
} catch {}
return next;
});
}, []);
// Keyboard shortcut: Cmd/Ctrl + \
useEffect(() => {

View file

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

View file

@ -1,20 +1,20 @@
"use client";
import { useAtom, useAtomValue } from "jotai";
import { PanelRight } from "lucide-react";
import { useAtomValue } from "jotai";
import { usePathname } from "next/navigation";
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { hitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { reportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
import { editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { activeTabAtom } from "@/atoms/tabs/tabs.atom";
import { activeTabAtom, tabsAtom } from "@/atoms/tabs/tabs.atom";
import { ChatHeader } from "@/components/new-chat/chat-header";
import { ChatShareButton } from "@/components/new-chat/chat-share-button";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useIsMobile } from "@/hooks/use-mobile";
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
import { cn } from "@/lib/utils";
interface HeaderProps {
mobileMenuTrigger?: React.ReactNode;
@ -25,9 +25,21 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const isMobile = useIsMobile();
const activeTab = useAtomValue(activeTabAtom);
const tabs = useAtomValue(tabsAtom);
const collapsed = useAtomValue(rightPanelCollapsedAtom);
const documentsOpen = useAtomValue(documentsSidebarOpenAtom);
const reportState = useAtomValue(reportPanelAtom);
const editorState = useAtomValue(editorPanelAtom);
const hitlEditState = useAtomValue(hitlEditPanelAtom);
const isChatPage = pathname?.includes("/new-chat") ?? false;
const isDocumentTab = activeTab?.type === "document";
const reportOpen = reportState.isOpen && !!reportState.reportId;
const editorOpen = editorState.isOpen && !!editorState.documentId;
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
const showExpandButton =
!isMobile && collapsed && (documentsOpen || reportOpen || editorOpen || hitlEditOpen);
const hasTabBar = tabs.length > 1;
const currentThreadState = useAtomValue(currentThreadAtom);
@ -49,15 +61,8 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
const handleVisibilityChange = (_visibility: ChatVisibility) => {};
const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom);
const documentsOpen = useAtomValue(documentsSidebarOpenAtom);
const reportState = useAtomValue(reportPanelAtom);
const reportOpen = reportState.isOpen && !!reportState.reportId;
const hasRightPanelContent = documentsOpen || reportOpen;
const showExpandButton = !isMobile && collapsed && hasRightPanelContent;
return (
<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 */}
<div className="flex flex-1 items-center gap-2 min-w-0">
{mobileMenuTrigger}
@ -67,26 +72,12 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
</div>
{/* 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 && (
<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>
</header>
);

View file

@ -55,7 +55,7 @@ export function RightPanelExpandButton() {
if (!collapsed || !hasContent) return null;
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>
<TooltipTrigger asChild>
<Button

View file

@ -3,6 +3,11 @@
import { useAtomValue } from "jotai";
import { AnimatePresence, motion } from "motion/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 { TooltipProvider } from "@/components/ui/tooltip";
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 { Header } from "../header";
import { IconRail } from "../icon-rail";
import { RightPanel } from "../right-panel/RightPanel";
import { RightPanel, RightPanelExpandButton } from "../right-panel/RightPanel";
import {
AllPrivateChatsSidebarContent,
AllSharedChatsSidebarContent,
@ -116,11 +121,26 @@ function MainContentPanel({
children: React.ReactNode;
}) {
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 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 (
<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 />
{isDocumentTab && activeTab.documentId && activeTab.searchSpaceId ? (

View file

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

View file

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

View file

@ -14,8 +14,8 @@ import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
import { expandedFolderIdsAtom } from "@/atoms/documents/folder.atoms";
import { agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms";
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
import { openDocumentTabAtom } from "@/atoms/tabs/tabs.atom";
import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog";
import type { DocumentNodeDoc } from "@/components/documents/DocumentNode";
import type { FolderDisplay } from "@/components/documents/FolderNode";
@ -35,21 +35,12 @@ import {
} from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerContent,
DrawerHandle,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useIsMobile } from "@/hooks/use-mobile";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { foldersApiService } from "@/lib/apis/folders-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
import { queries } from "@/zero/queries/index";
@ -95,12 +86,10 @@ export function DocumentsSidebar({
const searchSpaceId = Number(params.search_space_id);
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
const setRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom);
const openDocumentTab = useSetAtom(openDocumentTabAtom);
const openEditorPanel = useSetAtom(openEditorPanelAtom);
const { data: connectors } = useAtomValue(connectorsAtom);
const connectorCount = connectors?.length ?? 0;
const isMobileLayout = useIsMobile();
const [search, setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search, 250);
const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]);
@ -374,31 +363,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(
(doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => {
if (isMentioned) {
@ -557,7 +521,7 @@ export function DocumentsSidebar({
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 items-center gap-2">
{isMobile && (
@ -609,7 +573,7 @@ export function DocumentsSidebar({
</div>
{/* 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
type="button"
onClick={() => setConnectorDialogOpen(true)}
@ -716,24 +680,18 @@ export function DocumentsSidebar({
onCreateFolder={handleCreateFolder}
searchQuery={debouncedSearch.trim() || undefined}
onPreviewDocument={(doc) => {
if (isMobileLayout) {
handleViewDocumentPopup(doc);
} else {
openDocumentTab({
documentId: doc.id,
searchSpaceId,
title: doc.title,
});
}
openEditorPanel({
documentId: doc.id,
searchSpaceId,
title: doc.title,
});
}}
onEditDocument={(doc) => {
if (!isMobileLayout) {
openDocumentTab({
documentId: doc.id,
searchSpaceId,
title: doc.title,
});
}
openEditorPanel({
documentId: doc.id,
searchSpaceId,
title: doc.title,
});
}}
onDeleteDocument={(doc) => handleDeleteDocument(doc.id)}
onMoveDocument={handleMoveDocument}
@ -761,26 +719,6 @@ export function DocumentsSidebar({
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
open={bulkDeleteConfirmOpen}
onOpenChange={(open) => !open && !isBulkDeleting && setBulkDeleteConfirmOpen(false)}
@ -807,9 +745,10 @@ export function DocumentsSidebar({
handleBulkDeleteSelected();
}}
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>
</AlertDialogFooter>
</AlertDialogContent>

View file

@ -105,7 +105,7 @@ export function Sidebar({
>
{/* Header - search space name or collapse button when collapsed */}
{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
isCollapsed={isCollapsed}
onToggle={onToggleCollapse ?? (() => {})}
@ -113,7 +113,7 @@ export function Sidebar({
/>
</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
searchSpace={searchSpace}
isCollapsed={isCollapsed}

View file

@ -41,6 +41,8 @@ interface DocumentTabContentProps {
title?: string;
}
const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]);
export function DocumentTabContent({ documentId, searchSpaceId, title }: DocumentTabContentProps) {
const [doc, setDoc] = useState<DocumentContent | null>(null);
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) {
return (
<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">
{doc.title || title || "Untitled"}
</h1>
{doc.document_type === "NOTE" && (
{isEditable && (
<Button
variant="outline"
size="sm"

View file

@ -1,7 +1,7 @@
"use client";
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 {
activeTabIdAtom,
@ -58,11 +58,18 @@ export function TabBar({ onTabSwitch, onNewChat, className }: TabBarProps) {
if (tabs.length <= 1) return null;
return (
<div className={cn("flex items-center shrink-0 border-b bg-main-panel", className)}>
<div ref={scrollRef} className="flex items-center flex-1 overflow-x-auto scrollbar-none">
<div
className={cn(
"flex h-12 items-stretch shrink-0 border-b border-border/35 bg-main-panel",
className
)}
>
<div
ref={scrollRef}
className="flex h-full items-stretch flex-1 overflow-x-auto overflow-y-hidden scrollbar-hide [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
>
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
const Icon = tab.type === "document" ? FileText : MessageSquare;
return (
<button
@ -71,15 +78,15 @@ export function TabBar({ onTabSwitch, onNewChat, className }: TabBarProps) {
data-tab-id={tab.id}
onClick={() => handleTabClick(tab)}
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
? "bg-main-panel text-foreground"
: "bg-muted/30 text-muted-foreground hover:bg-muted/60 hover:text-foreground"
? "bg-muted/50 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" />}
<Icon className="size-3.5 shrink-0" />
<span className="truncate">{tab.title}</span>
<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">
{tab.title}
</span>
{/* biome-ignore lint/a11y/useSemanticElements: cannot nest button inside button */}
<span
role="button"
@ -92,10 +99,10 @@ export function TabBar({ onTabSwitch, onNewChat, className }: TabBarProps) {
}
}}
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
? "opacity-60 hover:opacity-100 hover:bg-muted"
: "opacity-0 group-hover: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 group-focus-within:opacity-60 hover:opacity-100!"
)}
>
<X className="size-3" />
@ -103,18 +110,19 @@ export function TabBar({ onTabSwitch, onNewChat, className }: TabBarProps) {
</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>
{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>
);
}

View file

@ -1,14 +1,14 @@
"use client";
import { useCallback, useState } from "react";
import { ImageConfigDialog } from "@/components/shared/image-config-dialog";
import { ModelConfigDialog } from "@/components/shared/model-config-dialog";
import type {
GlobalImageGenConfig,
GlobalNewLLMConfig,
ImageGenerationConfig,
NewLLMConfigPublic,
} from "@/contracts/types/new-llm-config.types";
import { ImageConfigDialog } from "./image-config-dialog";
import { ModelConfigDialog } from "./model-config-dialog";
import { ModelSelector } from "./model-selector";
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" />
<span className="text-sm font-medium">Add New Configuration</span>
<span className="text-sm font-medium">Add LLM Model</span>
</Button>
</div>
</CommandList>

View file

@ -106,16 +106,16 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
className="w-64 rounded-lg border bg-popover shadow-lg overflow-hidden"
style={containerStyle}
>
<div ref={scrollContainerRef} className="max-h-48 overflow-y-auto py-1">
{isLoading ? (
<div className="flex items-center justify-center py-3">
<Spinner className="size-4" />
</div>
) : isError ? (
<p className="px-3 py-2 text-xs text-destructive">Failed to load prompts</p>
) : filtered.length === 0 ? (
<p className="px-3 py-2 text-xs text-muted-foreground">No matching prompts</p>
) : (
<div ref={scrollContainerRef} className="max-h-48 overflow-y-auto py-1">
{isLoading ? (
<div className="flex items-center justify-center py-3">
<Spinner className="size-4" />
</div>
) : isError ? (
<p className="px-3 py-2 text-xs text-destructive">Failed to load prompts</p>
) : filtered.length === 0 ? (
<p className="px-3 py-2 text-xs text-muted-foreground">No matching prompts</p>
) : (
filtered.map((action, index) => (
<button
key={action.id}

View file

@ -666,27 +666,33 @@ export function OnboardingTour() {
}, [targetEl, isActive]);
const handleNext = useCallback(() => {
if (stepIndex < TOUR_STEPS.length - 1) {
retryCountRef.current = 0;
setShouldAnimate(true);
setStepIndex(stepIndex + 1);
} else {
// Tour completed - save to localStorage
if (user?.id) {
const tourKey = `surfsense-tour-${user.id}`;
localStorage.setItem(tourKey, "true");
retryCountRef.current = 0;
setShouldAnimate(true);
setStepIndex(prev => {
if (prev < TOUR_STEPS.length - 1) {
return prev + 1;
} else {
// Tour completed - save to localStorage
if (user?.id) {
const tourKey = `surfsense-tour-${user.id}`;
localStorage.setItem(tourKey, "true");
}
setIsActive(false);
return prev;
}
setIsActive(false);
}
}, [stepIndex, user?.id]);
});
}, [user?.id]);
const handlePrev = useCallback(() => {
if (stepIndex > 0) {
retryCountRef.current = 0;
setShouldAnimate(true);
setStepIndex(stepIndex - 1);
}
}, [stepIndex]);
retryCountRef.current = 0;
setShouldAnimate(true);
setStepIndex(prev => {
if (prev > 0) {
return prev - 1;
}
return prev;
});
}, []);
const handleSkip = useCallback(() => {
// Tour skipped - save to localStorage

View file

@ -10,6 +10,7 @@ import {
import { CheckIcon, CopyIcon } from "lucide-react";
import Image from "next/image";
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 { ToolFallback } from "@/components/assistant-ui/tool-fallback";
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"
data-role="assistant"
>
<div className="aui-assistant-message-content wrap-break-word px-2 text-foreground leading-relaxed">
<MessagePrimitive.Parts
components={{
Text: MarkdownText,
tools: {
by_name: {
generate_podcast: GeneratePodcastToolUI,
generate_report: GenerateReportToolUI,
generate_video_presentation: GenerateVideoPresentationToolUI,
display_image: GenerateImageToolUI,
generate_image: GenerateImageToolUI,
link_preview: () => null,
multi_link_preview: () => null,
scrape_webpage: () => null,
<CitationMetadataProvider>
<div className="aui-assistant-message-content wrap-break-word px-2 text-foreground leading-relaxed">
<MessagePrimitive.Parts
components={{
Text: MarkdownText,
tools: {
by_name: {
generate_podcast: GeneratePodcastToolUI,
generate_report: GenerateReportToolUI,
generate_video_presentation: GenerateVideoPresentationToolUI,
display_image: GenerateImageToolUI,
generate_image: GenerateImageToolUI,
web_search: () => 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">
<PublicAssistantActionBar />
</div>
<div className="aui-assistant-message-footer mt-1 mb-5 ml-2 flex">
<PublicAssistantActionBar />
</div>
</CitationMetadataProvider>
</MessagePrimitive.Root>
);
};

View file

@ -40,26 +40,17 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [saving, setSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
// Initialize state from fetched search space
useEffect(() => {
if (searchSpace) {
setName(searchSpace.name || "");
setDescription(searchSpace.description || "");
setHasChanges(false);
}
}, [searchSpace]);
}, [searchSpace?.name, searchSpace?.description]);
// Track changes
useEffect(() => {
if (searchSpace) {
const currentName = searchSpace.name || "";
const currentDescription = searchSpace.description || "";
const changed = currentName !== name || currentDescription !== description;
setHasChanges(changed);
}
}, [searchSpace, name, description]);
// Derive hasChanges during render
const hasChanges = !!searchSpace && ((searchSpace.name || "") !== name || (searchSpace.description || "") !== description);
const handleSave = async () => {
try {
@ -73,7 +64,6 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
},
});
setHasChanges(false);
await fetchSearchSpace();
} catch (error: any) {
console.error("Error saving search space details:", error);

View file

@ -1,31 +1,15 @@
"use client";
import { useAtomValue } from "jotai";
import {
AlertCircle,
Check,
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 { AlertCircle, Edit3, Info, Plus, RefreshCw, Trash2, Wand2 } from "lucide-react";
import { useMemo, useState } from "react";
import { deleteImageGenConfigMutationAtom } from "@/atoms/image-gen-config/image-gen-config-mutation.atoms";
import {
globalImageGenConfigsAtom,
imageGenConfigsAtom,
} from "@/atoms/image-gen-config/image-gen-config-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 {
AlertDialog,
@ -40,39 +24,9 @@ import {
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
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 { Spinner } from "@/components/ui/spinner";
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 { useMediaQuery } from "@/hooks/use-media-query";
import { getProviderIcon } from "@/lib/provider-icons";
@ -92,23 +46,12 @@ function getInitials(name: string): string {
export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
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 {
mutateAsync: deleteConfig,
isPending: isDeleting,
error: deleteError,
} = useAtomValue(deleteImageGenConfigMutationAtom);
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
const {
data: userConfigs,
@ -119,7 +62,6 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
const { data: globalConfigs = [], isFetching: globalLoading } =
useAtomValue(globalImageGenConfigsAtom);
// Members for user resolution
const { data: members } = useAtomValue(membersAtom);
const memberMap = useMemo(() => {
const map = new Map<string, { name: string; email?: string; avatarUrl?: string }>();
@ -135,7 +77,6 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
return map;
}, [members]);
// Permissions
const { data: access } = useAtomValue(myAccessAtom);
const canCreate = useMemo(() => {
if (!access) return false;
@ -147,126 +88,35 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
if (access.is_owner) return true;
return access.permissions?.includes("image_generations:delete") ?? false;
}, [access]);
// Backend uses image_generations:create for update as well
const canUpdate = canCreate;
const isReadOnly = !canCreate && !canDelete;
// Local state
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingConfig, setEditingConfig] = useState<ImageGenerationConfig | null>(null);
const [configToDelete, setConfigToDelete] = useState<ImageGenerationConfig | null>(null);
const isSubmitting = isCreating || isUpdating;
const isLoading = configsLoading || globalLoading;
const errors = [createError, updateError, 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 errors = [deleteError, fetchError].filter(Boolean) as Error[];
const openEditDialog = (config: ImageGenerationConfig) => {
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);
};
const openNewDialog = () => {
setEditingConfig(null);
resetForm();
setIsDialogOpen(true);
};
const selectedProvider = IMAGE_GEN_PROVIDERS.find((p) => p.value === formData.provider);
const suggestedModels = getImageGenModelsByProvider(formData.provider);
const handleDelete = async () => {
if (!configToDelete) return;
try {
await deleteConfig({ id: configToDelete.id, name: configToDelete.name });
setConfigToDelete(null);
} catch {
// Error handled by mutation
}
};
return (
<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">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">
<span className="font-medium">
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length} global
image model(s)
</span>{" "}
available from your administrator.
<p>
<span className="font-medium">
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length}{" "}
global image{" "}
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length === 1
? "model"
: "models"}
</span>{" "}
available from your administrator. Use the model selector to view and select them.
</p>
</AlertDescription>
</Alert>
)}
@ -348,31 +203,26 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
{/* Loading Skeleton */}
{isLoading && (
<div className="space-y-4 md:space-y-6">
{/* Your Image Models Section Skeleton */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<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" />
</div>
{/* Cards Grid Skeleton */}
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
<Card key={key} className="border-border/60">
<CardContent className="p-4 flex flex-col gap-3">
{/* Header */}
<div className="flex items-start justify-between gap-2">
<div className="space-y-1.5 flex-1 min-w-0">
<Skeleton className="h-4 w-28 md:w-32" />
<Skeleton className="h-3 w-40 md:w-48" />
</div>
</div>
{/* Provider + Model */}
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="h-5 w-24 rounded-md" />
</div>
{/* Footer */}
<div className="flex items-center gap-2 pt-2 border-t border-border/40">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-4 w-4 rounded-full" />
@ -529,216 +379,27 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
</div>
)}
{/* Create/Edit Dialog */}
<Dialog
{/* Create/Edit Dialog — shared component */}
<ImageConfigDialog
open={isDialogOpen}
onOpenChange={(open) => {
if (!open) {
setIsDialogOpen(false);
setEditingConfig(null);
resetForm();
}
setIsDialogOpen(open);
if (!open) setEditingConfig(null);
}}
>
<DialogContent
className="max-w-lg max-h-[90vh] overflow-y-auto"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<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>
config={editingConfig}
isGlobal={false}
searchSpaceId={searchSpaceId}
mode={editingConfig ? "edit" : "create"}
/>
{/* Delete Confirmation */}
<AlertDialog
open={!!configToDelete}
onOpenChange={(open) => !open && setConfigToDelete(null)}
>
<AlertDialogContent>
<AlertDialogContent className="select-none">
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<Trash2 className="h-5 w-5 text-destructive" />
Delete Image Model
</AlertDialogTitle>
<AlertDialogTitle>Delete Image Model</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">{configToDelete?.name}</span>?
@ -749,19 +410,10 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
<AlertDialogAction
onClick={handleDelete}
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" className="mr-2" />
Deleting
</>
) : (
<>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</>
)}
<span className={isDeleting ? "opacity-0" : ""}>Delete</span>
{isDeleting && <Spinner size="sm" className="absolute" />}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View file

@ -112,11 +112,11 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
const [assignments, setAssignments] = useState({
const [assignments, setAssignments] = useState(() => ({
agent_llm_id: preferences.agent_llm_id ?? "",
document_summary_llm_id: preferences.document_summary_llm_id ?? "",
image_generation_config_id: preferences.image_generation_config_id ?? "",
});
}));
const [hasChanges, setHasChanges] = useState(false);
const [isSaving, setIsSaving] = useState(false);
@ -129,7 +129,7 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
};
setAssignments(newAssignments);
setHasChanges(false);
}, [preferences]);
}, [preferences?.agent_llm_id, preferences?.document_summary_llm_id, preferences?.image_generation_config_id]);
const handleRoleAssignment = (prefKey: string, configId: string) => {
const newAssignments = {

View file

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

View file

@ -32,24 +32,16 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
const [customInstructions, setCustomInstructions] = useState("");
const [saving, setSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
// Initialize state from fetched search space
useEffect(() => {
if (searchSpace) {
setCustomInstructions(searchSpace.qna_custom_instructions || "");
setHasChanges(false);
}
}, [searchSpace]);
}, [searchSpace?.qna_custom_instructions]);
// Track changes
useEffect(() => {
if (searchSpace) {
const currentCustom = searchSpace.qna_custom_instructions || "";
const changed = currentCustom !== customInstructions;
setHasChanges(changed);
}
}, [searchSpace, customInstructions]);
// Derive hasChanges during render
const hasChanges = !!searchSpace && (searchSpace.qna_custom_instructions || "") !== customInstructions;
const handleSave = async () => {
try {
@ -74,7 +66,7 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
}
toast.success("System instructions saved successfully");
setHasChanges(false);
await fetchSearchSpace();
} catch (error: any) {
console.error("Error saving system instructions:", error);

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

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

@ -223,6 +223,7 @@ export function Audio({ id, src, title, durationMs, className }: AudioProps) {
onClick={togglePlayPause}
disabled={isLoading}
className="size-7 sm:size-8"
aria-label={isPlaying ? "Pause" : "Play"}
>
{isLoading ? (
<div className="size-3 sm:size-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
@ -234,7 +235,7 @@ export function Audio({ id, src, title, durationMs, className }: AudioProps) {
</Button>
<div className="group/volume flex items-center gap-1 sm:gap-1.5">
<Button variant="ghost" size="icon" onClick={toggleMute} className="size-7 sm:size-8">
<Button variant="ghost" size="icon" onClick={toggleMute} className="size-7 sm:size-8" aria-label={isMuted ? "Unmute" : "Mute"}>
{isMuted ? (
<VolumeXIcon className="size-3.5 sm:size-4" />
) : (

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

@ -149,17 +149,15 @@ function ApprovalCard({
const context = interruptData.context;
const page = context?.page;
const initialEditState = {
const [isPanelOpen, setIsPanelOpen] = useState(false);
const [editedArgs, setEditedArgs] = useState(() => ({
title: actionArgs.new_title
? String(actionArgs.new_title)
: (page?.page_title ?? args.new_title ?? ""),
content: actionArgs.new_content
? String(actionArgs.new_content)
: (page?.body ?? args.new_content ?? ""),
};
const [isPanelOpen, setIsPanelOpen] = useState(false);
const [editedArgs, setEditedArgs] = useState(initialEditState);
}));
const [hasPanelEdits, setHasPanelEdits] = useState(false);
const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom);

View file

@ -173,7 +173,8 @@ function ApprovalCard({
const issue = context?.issue;
const priorities = context?.priorities ?? [];
const initialEditState = {
const [isPanelOpen, setIsPanelOpen] = useState(false);
const [editedArgs, setEditedArgs] = useState(() => ({
summary: actionArgs.new_summary
? String(actionArgs.new_summary)
: (issue?.issue_title ?? args.new_summary ?? ""),
@ -183,10 +184,7 @@ function ApprovalCard({
priority: actionArgs.new_priority
? String(actionArgs.new_priority)
: (issue?.priority ?? args.new_priority ?? "__none__"),
};
const [isPanelOpen, setIsPanelOpen] = useState(false);
const [editedArgs, setEditedArgs] = useState(initialEditState);
}));
const [hasPanelEdits, setHasPanelEdits] = useState(false);
const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom);

View file

@ -182,7 +182,8 @@ function ApprovalCard({
const priorities = context?.priorities ?? [];
const issue = context?.issue;
const initialEditState = {
const [isPanelOpen, setIsPanelOpen] = useState(false);
const [editedArgs, setEditedArgs] = useState(() => ({
title: actionArgs.new_title
? String(actionArgs.new_title)
: (issue?.title ?? args.new_title ?? ""),
@ -202,10 +203,7 @@ function ApprovalCard({
labelIds: Array.isArray(actionArgs.new_label_ids)
? (actionArgs.new_label_ids as string[])
: (issue?.current_labels?.map((l) => l.id) ?? []),
};
const [isPanelOpen, setIsPanelOpen] = useState(false);
const [editedArgs, setEditedArgs] = useState(initialEditState);
}));
const [hasPanelEdits, setHasPanelEdits] = useState(false);
const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom);

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>) {
return (
<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}
/>
);

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" },
google_calendar: { src: "/connectors/google-calendar.svg", alt: "Google Calendar" },
google_drive: { src: "/connectors/google-drive.svg", alt: "Google Drive" },
onedrive: { src: "/connectors/onedrive.svg", alt: "OneDrive" },
notion: { src: "/connectors/notion.svg", alt: "Notion" },
linear: { src: "/connectors/linear.svg", alt: "Linear" },
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"],
google_calendar: ["GOOGLE_CALENDAR_CONNECTOR", "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR"],
google_drive: ["GOOGLE_DRIVE_CONNECTOR", "COMPOSIO_GOOGLE_DRIVE_CONNECTOR"],
onedrive: ["ONEDRIVE_CONNECTOR"],
notion: ["NOTION_CONNECTOR"],
linear: ["LINEAR_CONNECTOR"],
jira: ["JIRA_CONNECTOR"],

View file

@ -27,18 +27,45 @@ export interface ContentPartsState {
toolCallIndices: Map<string, number>;
}
function areThinkingStepsEqual(current: ThinkingStepData[], next: ThinkingStepData[]): boolean {
if (current.length !== next.length) return false;
for (let i = 0; i < current.length; i += 1) {
const curr = current[i];
const nxt = next[i];
if (curr.id !== nxt.id || curr.title !== nxt.title || curr.status !== nxt.status) {
return false;
}
if (curr.items.length !== nxt.items.length) return false;
for (let j = 0; j < curr.items.length; j += 1) {
if (curr.items[j] !== nxt.items[j]) return false;
}
}
return true;
}
export function updateThinkingSteps(
state: ContentPartsState,
steps: Map<string, ThinkingStepData>
): void {
): boolean {
const stepsArray = Array.from(steps.values());
const existingIdx = state.contentParts.findIndex((p) => p.type === "data-thinking-steps");
if (existingIdx >= 0) {
const existing = state.contentParts[existingIdx];
if (
existing?.type === "data-thinking-steps" &&
areThinkingStepsEqual(existing.data.steps, stepsArray)
) {
return false;
}
state.contentParts[existingIdx] = {
type: "data-thinking-steps",
data: { steps: stepsArray },
};
return true;
} else {
state.contentParts.unshift({
type: "data-thinking-steps",
@ -50,6 +77,56 @@ export function updateThinkingSteps(
for (const [id, idx] of state.toolCallIndices) {
state.toolCallIndices.set(id, idx + 1);
}
return true;
}
}
/**
* Coalesces rapid setMessages calls into at most one React state update per
* throttle interval. During streaming, SSE text-delta events arrive much
* faster than the user can perceive; throttling to ~50 ms lets React +
* ReactMarkdown do far fewer reconciliation passes, eliminating flicker.
*/
export class FrameBatchedUpdater {
private timerId: ReturnType<typeof setTimeout> | null = null;
private flusher: (() => void) | null = null;
private dirty = false;
private static readonly INTERVAL_MS = 50;
/** Mark state as dirty — will flush after the throttle interval. */
schedule(flush: () => void): void {
this.flusher = flush;
this.dirty = true;
if (this.timerId === null) {
this.timerId = setTimeout(() => {
this.timerId = null;
if (this.dirty) {
this.dirty = false;
this.flusher?.();
}
}, FrameBatchedUpdater.INTERVAL_MS);
}
}
/** Immediately flush any pending update (call on tool events or stream end). */
flush(): void {
if (this.timerId !== null) {
clearTimeout(this.timerId);
this.timerId = null;
}
if (this.dirty) {
this.dirty = false;
this.flusher?.();
}
}
dispose(): void {
if (this.timerId !== null) {
clearTimeout(this.timerId);
this.timerId = null;
}
this.dirty = false;
this.flusher = null;
}
}
@ -149,6 +226,7 @@ export type SSEEvent =
| { type: "data-thinking-step"; data: ThinkingStepData }
| { type: "data-thread-title-update"; data: { threadId: number; title: string } }
| { type: "data-interrupt-request"; data: Record<string, unknown> }
| { type: "data-documents-updated"; data: Record<string, unknown> }
| { type: "error"; errorText: string };
/**

View file

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

View file

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

View file

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

View file

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

View file

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