mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-27 17:56:25 +02:00
Merge upstream/dev
This commit is contained in:
commit
440762fb07
92 changed files with 3227 additions and 2502 deletions
|
|
@ -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**
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,10 @@ export function DashboardClientLayout({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isOnboardingPage) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentUploadDialogProvider>
|
<DocumentUploadDialogProvider>
|
||||||
<OnboardingTour />
|
<OnboardingTour />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>());
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
454
surfsense_web/components/shared/image-config-dialog.tsx
Normal file
454
surfsense_web/components/shared/image-config-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
333
surfsense_web/components/shared/model-config-dialog.tsx
Normal file
333
surfsense_web/components/shared/model-config-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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" />
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
8
surfsense_web/components/tool-ui/citation/_adapter.tsx
Normal file
8
surfsense_web/components/tool-ui/citation/_adapter.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
export { cn } from "@/lib/utils";
|
||||||
395
surfsense_web/components/tool-ui/citation/citation-list.tsx
Normal file
395
surfsense_web/components/tool-ui/citation/citation-list.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
248
surfsense_web/components/tool-ui/citation/citation.tsx
Normal file
248
surfsense_web/components/tool-ui/citation/citation.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
surfsense_web/components/tool-ui/citation/index.ts
Normal file
9
surfsense_web/components/tool-ui/citation/index.ts
Normal 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";
|
||||||
34
surfsense_web/components/tool-ui/citation/schema.ts
Normal file
34
surfsense_web/components/tool-ui/citation/schema.ts
Normal 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>;
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
5
surfsense_web/components/tool-ui/shared/media/index.ts
Normal file
5
surfsense_web/components/tool-ui/shared/media/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export {
|
||||||
|
openSafeNavigationHref,
|
||||||
|
resolveSafeNavigationHref,
|
||||||
|
} from "./safe-navigation";
|
||||||
|
export { sanitizeHref } from "./sanitize-href";
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
149
surfsense_web/components/tool-ui/shared/schema.ts
Normal file
149
surfsense_web/components/tool-ui/shared/schema.ts
Normal 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>;
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": "पहली कॉन्फ़िगरेशन जोड़ें",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": "添加首个配置",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue