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 1. 登录 SurfSense Dashboard
2. 进入 **Settings****API Keys** (或 **LLM Configurations**) 2. 进入 **Settings****API Keys** (或 **LLM Configurations**)
3. 点击 **Add New Configuration** 3. 点击 **Add LLM Model**
4. 从 **Provider** 下拉菜单中选择你的国产 LLM 提供商 4. 从 **Provider** 下拉菜单中选择你的国产 LLM 提供商
5. 填写必填字段(见下方各提供商详细配置) 5. 填写必填字段(见下方各提供商详细配置)
6. 点击 **Save** 6. 点击 **Save**

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -96,7 +96,7 @@ export function LocalLoginForm() {
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.95 }} exit={{ opacity: 0, y: -10, scale: 0.95 }}
transition={{ duration: 0.3 }} 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"> <div className="flex items-start gap-3">
<svg <svg
@ -109,7 +109,7 @@ export function LocalLoginForm() {
strokeWidth="2" strokeWidth="2"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="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> <title>Error Icon</title>
<circle cx="12" cy="12" r="10" /> <circle cx="12" cy="12" r="10" />
@ -118,13 +118,13 @@ export function LocalLoginForm() {
</svg> </svg>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-semibold mb-1">{error.title}</p> <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> </div>
<button <button
onClick={() => { onClick={() => {
setError({ title: null, message: null }); 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" aria-label="Dismiss error"
type="button" type="button"
> >
@ -152,7 +152,7 @@ export function LocalLoginForm() {
<div> <div>
<label <label
htmlFor="email" 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")} {t("email")}
</label> </label>
@ -163,10 +163,10 @@ export function LocalLoginForm() {
placeholder="you@example.com" placeholder="you@example.com"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} 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 ${ 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 error.title
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700" ? "border-destructive focus:border-destructive focus:ring-destructive"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700" : "border-border focus:border-primary focus:ring-primary"
}`} }`}
disabled={isLoggingIn} disabled={isLoggingIn}
/> />
@ -175,7 +175,7 @@ export function LocalLoginForm() {
<div> <div>
<label <label
htmlFor="password" 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")} {t("password")}
</label> </label>
@ -187,17 +187,17 @@ export function LocalLoginForm() {
placeholder="Enter your password" placeholder="Enter your password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} 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 error.title
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700" ? "border-destructive focus:border-destructive focus:ring-destructive"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700" : "border-border focus:border-primary focus:ring-primary"
}`} }`}
disabled={isLoggingIn} disabled={isLoggingIn}
/> />
<button <button
type="button" type="button"
onClick={() => setShowPassword((prev) => !prev)} 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")} aria-label={showPassword ? t("hide_password") : t("show_password")}
> >
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />} {showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
@ -208,12 +208,12 @@ export function LocalLoginForm() {
<button <button
type="submit" type="submit"
disabled={isLoggingIn} 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> <span className={isLoggingIn ? "invisible" : ""}>{t("sign_in")}</span>
{isLoggingIn && ( {isLoggingIn && (
<span className="absolute inset-0 flex items-center justify-center"> <span className="absolute inset-0 flex items-center justify-center">
<Spinner size="sm" className="text-white" /> <Spinner size="sm" className="text-primary-foreground" />
</span> </span>
)} )}
</button> </button>
@ -221,11 +221,11 @@ export function LocalLoginForm() {
{authType === "LOCAL" && ( {authType === "LOCAL" && (
<div className="mt-4 text-center text-sm"> <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")}{" "} {t("dont_have_account")}{" "}
<Link <Link
href="/register" 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")} {t("sign_up")}
</Link> </Link>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -250,7 +250,7 @@ export const ComposioDriveConfig: FC<ConnectorConfigProps> = ({ connector, onCon
<div className="space-y-2"> <div className="space-y-2">
<button <button
type="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" className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground transition-colors w-fit"
> >
Change Selection Change Selection

View file

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

View file

@ -220,7 +220,7 @@ export const OneDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfigCh
<div className="space-y-2"> <div className="space-y-2">
<button <button
type="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" className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground transition-colors w-fit"
> >
Change Selection Change Selection

View file

@ -86,7 +86,7 @@ export const WebcrawlerConfig: FC<ConnectorConfigProps> = ({ connector, onConfig
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" 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" 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"} {showApiKey ? "Hide" : "Show"}

View file

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

View file

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

View file

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

View file

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

View file

@ -5,6 +5,7 @@ import {
ThreadPrimitive, ThreadPrimitive,
useAui, useAui,
useAuiState, useAuiState,
useThreadViewportStore,
} from "@assistant-ui/react"; } from "@assistant-ui/react";
import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { import {
@ -113,7 +114,8 @@ const ThreadContent: FC = () => {
> >
<ThreadPrimitive.Viewport <ThreadPrimitive.Viewport
turnAnchor="top" 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}> <AuiIf condition={({ thread }) => thread.isEmpty}>
<ThreadWelcome /> <ThreadWelcome />
@ -128,7 +130,7 @@ const ThreadContent: FC = () => {
/> />
<ThreadPrimitive.ViewportFooter <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))" }} style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }}
> >
<ThreadScrollToBottom /> <ThreadScrollToBottom />
@ -349,7 +351,15 @@ const Composer: FC = () => {
const promptPickerRef = useRef<PromptPickerRef>(null); const promptPickerRef = useRef<PromptPickerRef>(null);
const { search_space_id, chat_id } = useParams(); const { search_space_id, chat_id } = useParams();
const aui = useAui(); const aui = useAui();
const threadViewportStore = useThreadViewportStore();
const hasAutoFocusedRef = useRef(false); const hasAutoFocusedRef = useRef(false);
const submitCleanupRef = useRef<(() => void) | null>(null);
useEffect(() => {
return () => {
submitCleanupRef.current?.();
};
}, []);
const [clipboardInitialText, setClipboardInitialText] = useState<string | undefined>(); const [clipboardInitialText, setClipboardInitialText] = useState<string | undefined>();
const clipboardLoadedRef = useRef(false); const clipboardLoadedRef = useRef(false);
@ -593,6 +603,63 @@ const Composer: FC = () => {
setMentionedDocuments([]); setMentionedDocuments([]);
setSidebarDocs([]); 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, showDocumentPopover,
showPromptPicker, showPromptPicker,
@ -602,6 +669,7 @@ const Composer: FC = () => {
aui, aui,
setMentionedDocuments, setMentionedDocuments,
setSidebarDocs, setSidebarDocs,
threadViewportStore,
]); ]);
const handleDocumentRemove = useCallback( const handleDocumentRemove = useCallback(

View file

@ -45,7 +45,7 @@ export const ToolFallback: ToolCallMessagePartComponent = ({
> >
<button <button
type="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" 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 <div

View file

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

View file

@ -93,7 +93,7 @@ export function CommentThread({
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground" className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={() => setIsRepliesExpanded(!isRepliesExpanded)} onClick={() => setIsRepliesExpanded(prev => !prev)}
> >
{isRepliesExpanded ? ( {isRepliesExpanded ? (
<ChevronDown className="mr-1 size-3" /> <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 { cn } from "@/lib/utils";
import { DND_TYPES } from "./FolderNode"; import { DND_TYPES } from "./FolderNode";
const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]);
export interface DocumentNodeDoc { export interface DocumentNodeDoc {
id: number; id: number;
title: string; title: string;
@ -78,7 +80,9 @@ export const DocumentNode = React.memo(function DocumentNode({
const statusState = doc.status?.state ?? "ready"; const statusState = doc.status?.state ?? "ready";
const isSelectable = statusState !== "pending" && statusState !== "processing"; const isSelectable = statusState !== "pending" && statusState !== "processing";
const isEditable = const isEditable =
doc.document_type === "NOTE" && statusState !== "pending" && statusState !== "processing"; EDITABLE_DOCUMENT_TYPES.has(doc.document_type) &&
statusState !== "pending" &&
statusState !== "processing";
const handleCheckChange = useCallback(() => { const handleCheckChange = useCallback(() => {
if (isSelectable) { if (isSelectable) {

View file

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

View file

@ -162,7 +162,7 @@ const MobileNav = ({ navItems, isScrolled, scrolledBgClassName }: any) => {
</Link> </Link>
<button <button
type="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" 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"} aria-label={open ? "Close menu" : "Open menu"}
> >

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,8 +14,8 @@ import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms"; import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
import { expandedFolderIdsAtom } from "@/atoms/documents/folder.atoms"; import { expandedFolderIdsAtom } from "@/atoms/documents/folder.atoms";
import { agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms"; import { agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms";
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom"; import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
import { openDocumentTabAtom } from "@/atoms/tabs/tabs.atom";
import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog"; import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog";
import type { DocumentNodeDoc } from "@/components/documents/DocumentNode"; import type { DocumentNodeDoc } from "@/components/documents/DocumentNode";
import type { FolderDisplay } from "@/components/documents/FolderNode"; import type { FolderDisplay } from "@/components/documents/FolderNode";
@ -35,21 +35,12 @@ import {
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerContent,
DrawerHandle,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { DocumentTypeEnum } from "@/contracts/types/document.types"; import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
import { useIsMobile } from "@/hooks/use-mobile";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { foldersApiService } from "@/lib/apis/folders-api.service"; import { foldersApiService } from "@/lib/apis/folders-api.service";
import { authenticatedFetch } from "@/lib/auth-utils"; import { authenticatedFetch } from "@/lib/auth-utils";
import { queries } from "@/zero/queries/index"; import { queries } from "@/zero/queries/index";
@ -95,12 +86,10 @@ export function DocumentsSidebar({
const searchSpaceId = Number(params.search_space_id); const searchSpaceId = Number(params.search_space_id);
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom); const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
const setRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom); const setRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom);
const openDocumentTab = useSetAtom(openDocumentTabAtom); const openEditorPanel = useSetAtom(openEditorPanelAtom);
const { data: connectors } = useAtomValue(connectorsAtom); const { data: connectors } = useAtomValue(connectorsAtom);
const connectorCount = connectors?.length ?? 0; const connectorCount = connectors?.length ?? 0;
const isMobileLayout = useIsMobile();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search, 250); const debouncedSearch = useDebouncedValue(search, 250);
const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]); const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]);
@ -374,31 +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( const handleToggleChatMention = useCallback(
(doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => { (doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => {
if (isMentioned) { if (isMentioned) {
@ -557,7 +521,7 @@ export function DocumentsSidebar({
const documentsContent = ( const documentsContent = (
<> <>
<div className="shrink-0 flex h-14 items-center px-4"> <div className="shrink-0 flex h-12 items-center px-4">
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isMobile && ( {isMobile && (
@ -609,7 +573,7 @@ export function DocumentsSidebar({
</div> </div>
{/* Connected tools strip */} {/* Connected tools strip */}
<div className="shrink-0 mx-4 mt-2 mb-3 flex select-none items-center gap-2 rounded-lg border bg-muted/50 transition-colors hover:bg-muted/80"> <div className="shrink-0 mx-4 mt-4 mb-4 flex select-none items-center gap-2 rounded-lg border bg-muted/50 transition-colors hover:bg-muted/80">
<button <button
type="button" type="button"
onClick={() => setConnectorDialogOpen(true)} onClick={() => setConnectorDialogOpen(true)}
@ -716,24 +680,18 @@ export function DocumentsSidebar({
onCreateFolder={handleCreateFolder} onCreateFolder={handleCreateFolder}
searchQuery={debouncedSearch.trim() || undefined} searchQuery={debouncedSearch.trim() || undefined}
onPreviewDocument={(doc) => { onPreviewDocument={(doc) => {
if (isMobileLayout) { openEditorPanel({
handleViewDocumentPopup(doc); documentId: doc.id,
} else { searchSpaceId,
openDocumentTab({ title: doc.title,
documentId: doc.id, });
searchSpaceId,
title: doc.title,
});
}
}} }}
onEditDocument={(doc) => { onEditDocument={(doc) => {
if (!isMobileLayout) { openEditorPanel({
openDocumentTab({ documentId: doc.id,
documentId: doc.id, searchSpaceId,
searchSpaceId, title: doc.title,
title: doc.title, });
});
}
}} }}
onDeleteDocument={(doc) => handleDeleteDocument(doc.id)} onDeleteDocument={(doc) => handleDeleteDocument(doc.id)}
onMoveDocument={handleMoveDocument} onMoveDocument={handleMoveDocument}
@ -761,26 +719,6 @@ export function DocumentsSidebar({
onConfirm={handleCreateFolderConfirm} onConfirm={handleCreateFolderConfirm}
/> />
<Drawer open={!!viewingDoc} onOpenChange={(open) => !open && handleCloseViewer()}>
<DrawerContent className="max-h-[85vh] flex flex-col">
<DrawerHandle />
<DrawerHeader className="text-left shrink-0">
<DrawerTitle className="text-base leading-tight break-words">
{viewingDoc?.title}
</DrawerTitle>
</DrawerHeader>
<div className="overflow-y-auto flex-1 min-h-0 px-4 pb-6 select-text text-xs [&_h1]:text-base! [&_h1]:mt-3! [&_h2]:text-sm! [&_h2]:mt-2! [&_h3]:text-xs! [&_h3]:mt-2!">
{viewingLoading ? (
<div className="flex items-center justify-center py-12">
<Spinner size="lg" className="text-muted-foreground" />
</div>
) : (
<MarkdownViewer content={viewingContent} />
)}
</div>
</DrawerContent>
</Drawer>
<AlertDialog <AlertDialog
open={bulkDeleteConfirmOpen} open={bulkDeleteConfirmOpen}
onOpenChange={(open) => !open && !isBulkDeleting && setBulkDeleteConfirmOpen(false)} onOpenChange={(open) => !open && !isBulkDeleting && setBulkDeleteConfirmOpen(false)}
@ -807,9 +745,10 @@ export function DocumentsSidebar({
handleBulkDeleteSelected(); handleBulkDeleteSelected();
}} }}
disabled={isBulkDeleting} disabled={isBulkDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
> >
{isBulkDeleting ? <Spinner size="sm" /> : "Delete"} <span className={isBulkDeleting ? "opacity-0" : ""}>Delete</span>
{isBulkDeleting && <Spinner size="sm" className="absolute" />}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -223,6 +223,7 @@ export function Audio({ id, src, title, durationMs, className }: AudioProps) {
onClick={togglePlayPause} onClick={togglePlayPause}
disabled={isLoading} disabled={isLoading}
className="size-7 sm:size-8" className="size-7 sm:size-8"
aria-label={isPlaying ? "Pause" : "Play"}
> >
{isLoading ? ( {isLoading ? (
<div className="size-3 sm:size-4 animate-spin rounded-full border-2 border-current border-t-transparent" /> <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> </Button>
<div className="group/volume flex items-center gap-1 sm:gap-1.5"> <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 ? ( {isMuted ? (
<VolumeXIcon className="size-3.5 sm:size-4" /> <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 context = interruptData.context;
const page = context?.page; const page = context?.page;
const initialEditState = { const [isPanelOpen, setIsPanelOpen] = useState(false);
const [editedArgs, setEditedArgs] = useState(() => ({
title: actionArgs.new_title title: actionArgs.new_title
? String(actionArgs.new_title) ? String(actionArgs.new_title)
: (page?.page_title ?? args.new_title ?? ""), : (page?.page_title ?? args.new_title ?? ""),
content: actionArgs.new_content content: actionArgs.new_content
? String(actionArgs.new_content) ? String(actionArgs.new_content)
: (page?.body ?? args.new_content ?? ""), : (page?.body ?? args.new_content ?? ""),
}; }));
const [isPanelOpen, setIsPanelOpen] = useState(false);
const [editedArgs, setEditedArgs] = useState(initialEditState);
const [hasPanelEdits, setHasPanelEdits] = useState(false); const [hasPanelEdits, setHasPanelEdits] = useState(false);
const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom); const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom);

View file

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

View file

@ -182,7 +182,8 @@ function ApprovalCard({
const priorities = context?.priorities ?? []; const priorities = context?.priorities ?? [];
const issue = context?.issue; const issue = context?.issue;
const initialEditState = { const [isPanelOpen, setIsPanelOpen] = useState(false);
const [editedArgs, setEditedArgs] = useState(() => ({
title: actionArgs.new_title title: actionArgs.new_title
? String(actionArgs.new_title) ? String(actionArgs.new_title)
: (issue?.title ?? args.new_title ?? ""), : (issue?.title ?? args.new_title ?? ""),
@ -202,10 +203,7 @@ function ApprovalCard({
labelIds: Array.isArray(actionArgs.new_label_ids) labelIds: Array.isArray(actionArgs.new_label_ids)
? (actionArgs.new_label_ids as string[]) ? (actionArgs.new_label_ids as string[])
: (issue?.current_labels?.map((l) => l.id) ?? []), : (issue?.current_labels?.map((l) => l.id) ?? []),
}; }));
const [isPanelOpen, setIsPanelOpen] = useState(false);
const [editedArgs, setEditedArgs] = useState(initialEditState);
const [hasPanelEdits, setHasPanelEdits] = useState(false); const [hasPanelEdits, setHasPanelEdits] = useState(false);
const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom); 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>) { function DrawerHandle({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return ( return (
<div <div
className={cn("mx-auto mt-4 h-1.5 w-12 rounded-full bg-muted-foreground/40", className)} className={cn("mx-auto mt-4 h-2 w-12 rounded-full bg-muted-foreground/40", className)}
{...props} {...props}
/> />
); );

View file

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

View file

@ -27,18 +27,45 @@ export interface ContentPartsState {
toolCallIndices: Map<string, number>; 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( export function updateThinkingSteps(
state: ContentPartsState, state: ContentPartsState,
steps: Map<string, ThinkingStepData> steps: Map<string, ThinkingStepData>
): void { ): boolean {
const stepsArray = Array.from(steps.values()); const stepsArray = Array.from(steps.values());
const existingIdx = state.contentParts.findIndex((p) => p.type === "data-thinking-steps"); const existingIdx = state.contentParts.findIndex((p) => p.type === "data-thinking-steps");
if (existingIdx >= 0) { if (existingIdx >= 0) {
const existing = state.contentParts[existingIdx];
if (
existing?.type === "data-thinking-steps" &&
areThinkingStepsEqual(existing.data.steps, stepsArray)
) {
return false;
}
state.contentParts[existingIdx] = { state.contentParts[existingIdx] = {
type: "data-thinking-steps", type: "data-thinking-steps",
data: { steps: stepsArray }, data: { steps: stepsArray },
}; };
return true;
} else { } else {
state.contentParts.unshift({ state.contentParts.unshift({
type: "data-thinking-steps", type: "data-thinking-steps",
@ -50,6 +77,56 @@ export function updateThinkingSteps(
for (const [id, idx] of state.toolCallIndices) { for (const [id, idx] of state.toolCallIndices) {
state.toolCallIndices.set(id, idx + 1); 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-thinking-step"; data: ThinkingStepData }
| { type: "data-thread-title-update"; data: { threadId: number; title: string } } | { type: "data-thread-title-update"; data: { threadId: number; title: string } }
| { type: "data-interrupt-request"; data: Record<string, unknown> } | { type: "data-interrupt-request"; data: Record<string, unknown> }
| { type: "data-documents-updated"; data: Record<string, unknown> }
| { type: "error"; errorText: string }; | { type: "error"; errorText: string };
/** /**

View file

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

View file

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

View file

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

View file

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

View file

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