mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-25 19:15:18 +02:00
chore: ran linting
This commit is contained in:
parent
74826b3714
commit
04691d572b
61 changed files with 1962 additions and 1516 deletions
|
|
@ -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,
|
"id": c.id,
|
||||||
"name": c.name,
|
"name": c.name,
|
||||||
"user_email": cfg.get("user_email"),
|
"user_email": cfg.get("user_email"),
|
||||||
"auth_expired": cfg.get("auth_expired", False),
|
"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,13 +208,15 @@ 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:
|
if resp.status_code != 200:
|
||||||
return f"Download failed: {resp.status_code}"
|
return f"Download failed: {resp.status_code}"
|
||||||
with open(dest_path, "wb") as f:
|
with open(dest_path, "wb") as f:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -1076,7 +1076,11 @@ async def _stream_agent_events(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
elif tool_name == "web_search":
|
elif tool_name == "web_search":
|
||||||
xml = tool_output.get("result", str(tool_output)) if isinstance(tool_output, dict) else str(tool_output)
|
xml = (
|
||||||
|
tool_output.get("result", str(tool_output))
|
||||||
|
if isinstance(tool_output, dict)
|
||||||
|
else str(tool_output)
|
||||||
|
)
|
||||||
citations: dict[str, dict[str, str]] = {}
|
citations: dict[str, dict[str, str]] = {}
|
||||||
for m in re.finditer(
|
for m in re.finditer(
|
||||||
r"<title><!\[CDATA\[(.*?)\]\]></title>\s*<url><!\[CDATA\[(.*?)\]\]></url>",
|
r"<title><!\[CDATA\[(.*?)\]\]></title>\s*<url><!\[CDATA\[(.*?)\]\]></url>",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -744,7 +744,11 @@ export function DocumentsTableShell({
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-48">
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
<DropdownMenuItem onClick={() => onOpenInTab ? onOpenInTab(doc) : handleViewDocument(doc)}>
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
onOpenInTab ? onOpenInTab(doc) : handleViewDocument(doc)
|
||||||
|
}
|
||||||
|
>
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
Open
|
Open
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
|
||||||
|
|
@ -190,9 +190,7 @@ export default function OnboardPage() {
|
||||||
<span className={isSubmitting ? "opacity-0" : ""}>Start Using SurfSense</span>
|
<span className={isSubmitting ? "opacity-0" : ""}>Start Using SurfSense</span>
|
||||||
{isSubmitting && <Spinner size="sm" className="absolute" />}
|
{isSubmitting && <Spinner size="sm" className="absolute" />}
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">You can add more configurations later</p>
|
||||||
You can add more configurations later
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -308,7 +308,8 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) {
|
||||||
{invitesLoading ? (
|
{invitesLoading ? (
|
||||||
<Skeleton className="h-9 w-32 rounded-md" />
|
<Skeleton className="h-9 w-32 rounded-md" />
|
||||||
) : (
|
) : (
|
||||||
canInvite && activeInvites.length > 0 && (
|
canInvite &&
|
||||||
|
activeInvites.length > 0 && (
|
||||||
<AllInvitesDialog invites={activeInvites} onRevokeInvite={handleRevokeInvite} />
|
<AllInvitesDialog invites={activeInvites} onRevokeInvite={handleRevokeInvite} />
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,11 @@
|
||||||
import { PenLine, Plus, Sparkles, Trash2 } from "lucide-react";
|
import { PenLine, Plus, Sparkles, Trash2 } from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { PromptRead } from "@/contracts/types/prompts.types";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import type { PromptRead } from "@/contracts/types/prompts.types";
|
||||||
import { promptsApiService } from "@/lib/apis/prompts-api.service";
|
import { promptsApiService } from "@/lib/apis/prompts-api.service";
|
||||||
|
|
||||||
interface PromptFormData {
|
interface PromptFormData {
|
||||||
|
|
@ -99,7 +99,9 @@ export function PromptsContent() {
|
||||||
<div className="space-y-6 min-w-0 overflow-hidden">
|
<div className="space-y-6 min-w-0 overflow-hidden">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Create prompt templates triggered with <kbd className="rounded border bg-muted px-1.5 py-0.5 text-xs font-mono">/</kbd> in the chat composer.
|
Create prompt templates triggered with{" "}
|
||||||
|
<kbd className="rounded border bg-muted px-1.5 py-0.5 text-xs font-mono">/</kbd> in the
|
||||||
|
chat composer.
|
||||||
</p>
|
</p>
|
||||||
{!showForm && (
|
{!showForm && (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -144,7 +146,11 @@ export function PromptsContent() {
|
||||||
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm outline-none resize-none focus:ring-1 focus:ring-ring"
|
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm outline-none resize-none focus:ring-1 focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Use <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">{"{selection}"}</code> to insert the input text. If omitted, the text is appended automatically.
|
Use{" "}
|
||||||
|
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">
|
||||||
|
{"{selection}"}
|
||||||
|
</code>{" "}
|
||||||
|
to insert the input text. If omitted, the text is appended automatically.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -153,7 +159,9 @@ export function PromptsContent() {
|
||||||
<select
|
<select
|
||||||
id="prompt-mode"
|
id="prompt-mode"
|
||||||
value={formData.mode}
|
value={formData.mode}
|
||||||
onChange={(e) => setFormData((p) => ({ ...p, mode: e.target.value as "transform" | "explore" }))}
|
onChange={(e) =>
|
||||||
|
setFormData((p) => ({ ...p, mode: e.target.value as "transform" | "explore" }))
|
||||||
|
}
|
||||||
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring"
|
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring"
|
||||||
>
|
>
|
||||||
<option value="transform">Transform — rewrites or modifies your text</option>
|
<option value="transform">Transform — rewrites or modifies your text</option>
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,10 @@ export const deleteNewLLMConfigMutationAtom = atomWithMutation((get) => {
|
||||||
mutationFn: async (request: DeleteNewLLMConfigRequest & { name: string }) => {
|
mutationFn: async (request: DeleteNewLLMConfigRequest & { name: string }) => {
|
||||||
return newLLMConfigApiService.deleteConfig({ id: request.id });
|
return newLLMConfigApiService.deleteConfig({ id: request.id });
|
||||||
},
|
},
|
||||||
onSuccess: (_: DeleteNewLLMConfigResponse, request: DeleteNewLLMConfigRequest & { name: string }) => {
|
onSuccess: (
|
||||||
|
_: DeleteNewLLMConfigResponse,
|
||||||
|
request: DeleteNewLLMConfigRequest & { name: string }
|
||||||
|
) => {
|
||||||
toast.success(`${request.name} deleted`);
|
toast.success(`${request.name} deleted`);
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
cacheKeys.newLLMConfigs.all(Number(searchSpaceId)),
|
cacheKeys.newLLMConfigs.all(Number(searchSpaceId)),
|
||||||
|
|
|
||||||
|
|
@ -143,9 +143,7 @@ export const updateChatTabTitleAtom = atom(
|
||||||
set(tabsStateAtom, {
|
set(tabsStateAtom, {
|
||||||
...state,
|
...state,
|
||||||
activeTabId: tabId,
|
activeTabId: tabId,
|
||||||
tabs: state.tabs.map((t) =>
|
tabs: state.tabs.map((t) => (t.id === "chat-new" ? { ...t, id: tabId, chatId, title } : t)),
|
||||||
t.id === "chat-new" ? { ...t, id: tabId, chatId, title } : t
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,14 @@ import {
|
||||||
useAuiState,
|
useAuiState,
|
||||||
} from "@assistant-ui/react";
|
} from "@assistant-ui/react";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { CheckIcon, ClipboardPaste, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react";
|
import {
|
||||||
|
CheckIcon,
|
||||||
|
ClipboardPaste,
|
||||||
|
CopyIcon,
|
||||||
|
DownloadIcon,
|
||||||
|
MessageSquare,
|
||||||
|
RefreshCwIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { commentsEnabledAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
|
import { commentsEnabledAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
|
||||||
|
|
@ -41,10 +48,6 @@ import {
|
||||||
CreateGoogleDriveFileToolUI,
|
CreateGoogleDriveFileToolUI,
|
||||||
DeleteGoogleDriveFileToolUI,
|
DeleteGoogleDriveFileToolUI,
|
||||||
} from "@/components/tool-ui/google-drive";
|
} from "@/components/tool-ui/google-drive";
|
||||||
import {
|
|
||||||
CreateOneDriveFileToolUI,
|
|
||||||
DeleteOneDriveFileToolUI,
|
|
||||||
} from "@/components/tool-ui/onedrive";
|
|
||||||
import {
|
import {
|
||||||
CreateJiraIssueToolUI,
|
CreateJiraIssueToolUI,
|
||||||
DeleteJiraIssueToolUI,
|
DeleteJiraIssueToolUI,
|
||||||
|
|
@ -60,6 +63,7 @@ import {
|
||||||
DeleteNotionPageToolUI,
|
DeleteNotionPageToolUI,
|
||||||
UpdateNotionPageToolUI,
|
UpdateNotionPageToolUI,
|
||||||
} from "@/components/tool-ui/notion";
|
} from "@/components/tool-ui/notion";
|
||||||
|
import { CreateOneDriveFileToolUI, DeleteOneDriveFileToolUI } from "@/components/tool-ui/onedrive";
|
||||||
import { SandboxExecuteToolUI } from "@/components/tool-ui/sandbox-execute";
|
import { SandboxExecuteToolUI } from "@/components/tool-ui/sandbox-execute";
|
||||||
import { 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";
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,9 @@ interface MessageContent {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CitationMetadataProvider: FC<{ children: ReactNode }> = ({ children }) => {
|
export const CitationMetadataProvider: FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
const content = useAuiState(({ message }) => (message as { content?: MessageContent[] })?.content);
|
const content = useAuiState(
|
||||||
|
({ message }) => (message as { content?: MessageContent[] })?.content
|
||||||
|
);
|
||||||
|
|
||||||
const metadataMap = useMemo<CitationMetadataMap>(() => {
|
const metadataMap = useMemo<CitationMetadataMap>(() => {
|
||||||
if (!content || !Array.isArray(content)) return new Map();
|
if (!content || !Array.isArray(content)) return new Map();
|
||||||
|
|
@ -51,7 +53,9 @@ export const CitationMetadataProvider: FC<{ children: ReactNode }> = ({ children
|
||||||
}, [content]);
|
}, [content]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CitationMetadataContext.Provider value={metadataMap}>{children}</CitationMetadataContext.Provider>
|
<CitationMetadataContext.Provider value={metadataMap}>
|
||||||
|
{children}
|
||||||
|
</CitationMetadataContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,6 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} modal={false} onOpenChange={handleOpenChange}>
|
<Dialog open={isOpen} modal={false} onOpenChange={handleOpenChange}>
|
||||||
|
|
||||||
{isOpen &&
|
{isOpen &&
|
||||||
createPortal(
|
createPortal(
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -212,8 +212,7 @@ export const OneDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfigCh
|
||||||
|
|
||||||
{isAuthExpired && (
|
{isAuthExpired && (
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-500">
|
<p className="text-xs text-amber-600 dark:text-amber-500">
|
||||||
Your OneDrive authentication has expired. Please re-authenticate using the button
|
Your OneDrive authentication has expired. Please re-authenticate using the button below.
|
||||||
below.
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,9 @@ import { LinkupApiConfig } from "./components/linkup-api-config";
|
||||||
import { LumaConfig } from "./components/luma-config";
|
import { LumaConfig } from "./components/luma-config";
|
||||||
import { MCPConfig } from "./components/mcp-config";
|
import { MCPConfig } from "./components/mcp-config";
|
||||||
import { ObsidianConfig } from "./components/obsidian-config";
|
import { ObsidianConfig } from "./components/obsidian-config";
|
||||||
|
import { OneDriveConfig } from "./components/onedrive-config";
|
||||||
import { SlackConfig } from "./components/slack-config";
|
import { SlackConfig } from "./components/slack-config";
|
||||||
import { TavilyApiConfig } from "./components/tavily-api-config";
|
import { TavilyApiConfig } from "./components/tavily-api-config";
|
||||||
import { OneDriveConfig } from "./components/onedrive-config";
|
|
||||||
import { TeamsConfig } from "./components/teams-config";
|
import { TeamsConfig } from "./components/teams-config";
|
||||||
import { WebcrawlerConfig } from "./components/webcrawler-config";
|
import { WebcrawlerConfig } from "./components/webcrawler-config";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -544,7 +544,12 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (slashIndex !== -1 && (slashIndex === 0 || textContent[slashIndex - 1] === " " || textContent[slashIndex - 1] === "\n")) {
|
if (
|
||||||
|
slashIndex !== -1 &&
|
||||||
|
(slashIndex === 0 ||
|
||||||
|
textContent[slashIndex - 1] === " " ||
|
||||||
|
textContent[slashIndex - 1] === "\n")
|
||||||
|
) {
|
||||||
const query = textContent.slice(slashIndex + 1, cursorPos);
|
const query = textContent.slice(slashIndex + 1, cursorPos);
|
||||||
if (!query.startsWith(" ")) {
|
if (!query.startsWith(" ")) {
|
||||||
shouldTriggerAction = true;
|
shouldTriggerAction = true;
|
||||||
|
|
@ -575,7 +580,15 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
|
|
||||||
// Notify parent of change
|
// Notify parent of change
|
||||||
onChange?.(text, Array.from(mentionedDocs.values()));
|
onChange?.(text, Array.from(mentionedDocs.values()));
|
||||||
}, [getText, mentionedDocs, onChange, onMentionTrigger, onMentionClose, onActionTrigger, onActionClose]);
|
}, [
|
||||||
|
getText,
|
||||||
|
mentionedDocs,
|
||||||
|
onChange,
|
||||||
|
onMentionTrigger,
|
||||||
|
onMentionClose,
|
||||||
|
onActionTrigger,
|
||||||
|
onActionClose,
|
||||||
|
]);
|
||||||
|
|
||||||
// Handle keydown
|
// Handle keydown
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
|
|
|
||||||
|
|
@ -395,7 +395,10 @@ const defaultComponents = memoizeMarkdownComponents({
|
||||||
if (!isCodeBlock) {
|
if (!isCodeBlock) {
|
||||||
return (
|
return (
|
||||||
<code
|
<code
|
||||||
className={cn("aui-md-inline-code rounded-md border bg-muted px-1.5 py-0.5 font-mono text-[0.9em] font-normal", className)}
|
className={cn(
|
||||||
|
"aui-md-inline-code rounded-md border bg-muted px-1.5 py-0.5 font-mono text-[0.9em] font-normal",
|
||||||
|
className
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -61,11 +61,11 @@ import {
|
||||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||||
import { UserMessage } from "@/components/assistant-ui/user-message";
|
import { UserMessage } from "@/components/assistant-ui/user-message";
|
||||||
import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/components/layout/ui/sidebar/SidebarSlideOutPanel";
|
import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/components/layout/ui/sidebar/SidebarSlideOutPanel";
|
||||||
import { PromptPicker, type PromptPickerRef } from "@/components/new-chat/prompt-picker";
|
|
||||||
import {
|
import {
|
||||||
DocumentMentionPicker,
|
DocumentMentionPicker,
|
||||||
type DocumentMentionPickerRef,
|
type DocumentMentionPickerRef,
|
||||||
} from "@/components/new-chat/document-mention-picker";
|
} from "@/components/new-chat/document-mention-picker";
|
||||||
|
import { PromptPicker, type PromptPickerRef } from "@/components/new-chat/prompt-picker";
|
||||||
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
|
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
|
||||||
|
|
@ -356,7 +356,9 @@ const Composer: FC = () => {
|
||||||
const submitCleanupRef = useRef<(() => void) | null>(null);
|
const submitCleanupRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => { submitCleanupRef.current?.(); };
|
return () => {
|
||||||
|
submitCleanupRef.current?.();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [clipboardInitialText, setClipboardInitialText] = useState<string | undefined>();
|
const [clipboardInitialText, setClipboardInitialText] = useState<string | undefined>();
|
||||||
|
|
@ -498,7 +500,9 @@ const Composer: FC = () => {
|
||||||
}
|
}
|
||||||
const finalPrompt = action.prompt.includes("{selection}")
|
const finalPrompt = action.prompt.includes("{selection}")
|
||||||
? action.prompt.replace("{selection}", () => userText)
|
? action.prompt.replace("{selection}", () => userText)
|
||||||
: userText ? `${action.prompt}\n\n${userText}` : action.prompt;
|
: userText
|
||||||
|
? `${action.prompt}\n\n${userText}`
|
||||||
|
: action.prompt;
|
||||||
aui.composer().setText(finalPrompt);
|
aui.composer().setText(finalPrompt);
|
||||||
aui.composer().send();
|
aui.composer().send();
|
||||||
editorRef.current?.clear();
|
editorRef.current?.clear();
|
||||||
|
|
@ -590,9 +594,7 @@ const Composer: FC = () => {
|
||||||
if (!showDocumentPopover && !showPromptPicker) {
|
if (!showDocumentPopover && !showPromptPicker) {
|
||||||
if (clipboardInitialText) {
|
if (clipboardInitialText) {
|
||||||
const userText = editorRef.current?.getText() ?? "";
|
const userText = editorRef.current?.getText() ?? "";
|
||||||
const combined = userText
|
const combined = userText ? `${userText}\n\n${clipboardInitialText}` : clipboardInitialText;
|
||||||
? `${userText}\n\n${clipboardInitialText}`
|
|
||||||
: clipboardInitialText;
|
|
||||||
aui.composer().setText(combined);
|
aui.composer().setText(combined);
|
||||||
setClipboardInitialText(undefined);
|
setClipboardInitialText(undefined);
|
||||||
}
|
}
|
||||||
|
|
@ -706,7 +708,7 @@ const Composer: FC = () => {
|
||||||
return (
|
return (
|
||||||
<ComposerPrimitive.Root
|
<ComposerPrimitive.Root
|
||||||
className="aui-composer-root relative flex w-full flex-col gap-2"
|
className="aui-composer-root relative flex w-full flex-col gap-2"
|
||||||
style={(showPromptPicker && clipboardInitialText) ? { marginBottom: 220 } : undefined}
|
style={showPromptPicker && clipboardInitialText ? { marginBottom: 220 } : undefined}
|
||||||
>
|
>
|
||||||
<ChatSessionStatus
|
<ChatSessionStatus
|
||||||
isAiResponding={isAiResponding}
|
isAiResponding={isAiResponding}
|
||||||
|
|
@ -714,7 +716,10 @@ const Composer: FC = () => {
|
||||||
currentUserId={currentUser?.id ?? null}
|
currentUserId={currentUser?.id ?? null}
|
||||||
members={members ?? []}
|
members={members ?? []}
|
||||||
/>
|
/>
|
||||||
<div ref={composerBoxRef} className="aui-composer-attachment-dropzone flex w-full flex-col overflow-hidden rounded-2xl border-input bg-muted pt-2 outline-none transition-shadow">
|
<div
|
||||||
|
ref={composerBoxRef}
|
||||||
|
className="aui-composer-attachment-dropzone flex w-full flex-col overflow-hidden rounded-2xl border-input bg-muted pt-2 outline-none transition-shadow"
|
||||||
|
>
|
||||||
{clipboardInitialText && (
|
{clipboardInitialText && (
|
||||||
<ClipboardChip
|
<ClipboardChip
|
||||||
text={clipboardInitialText}
|
text={clipboardInitialText}
|
||||||
|
|
@ -777,10 +782,11 @@ const Composer: FC = () => {
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
...(clipboardInitialText && composerBoxRef.current
|
...(clipboardInitialText && composerBoxRef.current
|
||||||
? { top: `${composerBoxRef.current.getBoundingClientRect().bottom + 8}px` }
|
? { top: `${composerBoxRef.current.getBoundingClientRect().bottom + 8}px` }
|
||||||
: { bottom: editorContainerRef.current
|
: {
|
||||||
|
bottom: editorContainerRef.current
|
||||||
? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 8}px`
|
? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 8}px`
|
||||||
: "200px" }
|
: "200px",
|
||||||
),
|
}),
|
||||||
left: editorContainerRef.current
|
left: editorContainerRef.current
|
||||||
? `${editorContainerRef.current.getBoundingClientRect().left}px`
|
? `${editorContainerRef.current.getBoundingClientRect().left}px`
|
||||||
: "50%",
|
: "50%",
|
||||||
|
|
|
||||||
|
|
@ -240,7 +240,9 @@ export function FolderTreeView({
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col items-center justify-center gap-1 px-4 py-12 text-muted-foreground">
|
<div className="flex flex-1 flex-col items-center justify-center gap-1 px-4 py-12 text-muted-foreground">
|
||||||
<p className="text-sm font-medium">No documents found</p>
|
<p className="text-sm font-medium">No documents found</p>
|
||||||
<p className="text-xs text-muted-foreground/70">Use the upload button or connect a source above</p>
|
<p className="text-xs text-muted-foreground/70">
|
||||||
|
Use the upload button or connect a source above
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ import { AlertCircle, XIcon } from "lucide-react";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
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 { MarkdownViewer } from "@/components/markdown-viewer";
|
|
||||||
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";
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,12 @@ import {
|
||||||
teamDialogAtom,
|
teamDialogAtom,
|
||||||
userSettingsDialogAtom,
|
userSettingsDialogAtom,
|
||||||
} from "@/atoms/settings/settings-dialog.atoms";
|
} from "@/atoms/settings/settings-dialog.atoms";
|
||||||
import { removeChatTabAtom, resetTabsAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
|
import {
|
||||||
|
removeChatTabAtom,
|
||||||
|
resetTabsAtom,
|
||||||
|
syncChatTabAtom,
|
||||||
|
type Tab,
|
||||||
|
} from "@/atoms/tabs/tabs.atom";
|
||||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
import { 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";
|
||||||
|
|
@ -846,7 +851,9 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
disabled={isRenamingChat || !newChatTitle.trim()}
|
disabled={isRenamingChat || !newChatTitle.trim()}
|
||||||
className="relative"
|
className="relative"
|
||||||
>
|
>
|
||||||
<span className={isRenamingChat ? "opacity-0" : ""}>{tSidebar("rename") || "Rename"}</span>
|
<span className={isRenamingChat ? "opacity-0" : ""}>
|
||||||
|
{tSidebar("rename") || "Rename"}
|
||||||
|
</span>
|
||||||
{isRenamingChat && (
|
{isRenamingChat && (
|
||||||
<span className="absolute 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" />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { hitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
|
|
||||||
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 { editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||||
|
|
@ -37,7 +37,8 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
|
||||||
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
||||||
const editorOpen = editorState.isOpen && !!editorState.documentId;
|
const editorOpen = editorState.isOpen && !!editorState.documentId;
|
||||||
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
|
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
|
||||||
const showExpandButton = !isMobile && collapsed && (documentsOpen || reportOpen || editorOpen || hitlEditOpen);
|
const showExpandButton =
|
||||||
|
!isMobile && collapsed && (documentsOpen || reportOpen || editorOpen || hitlEditOpen);
|
||||||
const hasTabBar = tabs.length > 1;
|
const hasTabBar = tabs.length > 1;
|
||||||
|
|
||||||
const currentThreadState = useAtomValue(currentThreadAtom);
|
const currentThreadState = useAtomValue(currentThreadAtom);
|
||||||
|
|
@ -71,7 +72,9 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side - Actions */}
|
{/* Right side - Actions */}
|
||||||
<div className={cn("ml-auto flex items-center gap-2", showExpandButton && !hasTabBar && "mr-10")}>
|
<div
|
||||||
|
className={cn("ml-auto flex items-center gap-2", showExpandButton && !hasTabBar && "mr-10")}
|
||||||
|
>
|
||||||
{hasThread && (
|
{hasThread && (
|
||||||
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />
|
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useSetAtom } from "jotai";
|
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
import { useSetAtom } from "jotai";
|
||||||
import {
|
import {
|
||||||
ArchiveIcon,
|
ArchiveIcon,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
|
|
@ -19,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 {
|
||||||
|
|
@ -42,7 +43,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
||||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||||
import { useLongPress } from "@/hooks/use-long-press";
|
import { useLongPress } from "@/hooks/use-long-press";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { removeChatTabAtom } from "@/atoms/tabs/tabs.atom";
|
|
||||||
import {
|
import {
|
||||||
deleteThread,
|
deleteThread,
|
||||||
fetchThreads,
|
fetchThreads,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useSetAtom } from "jotai";
|
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
import { useSetAtom } from "jotai";
|
||||||
import {
|
import {
|
||||||
ArchiveIcon,
|
ArchiveIcon,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
|
|
@ -19,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 {
|
||||||
|
|
@ -42,7 +43,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
||||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||||
import { useLongPress } from "@/hooks/use-long-press";
|
import { useLongPress } from "@/hooks/use-long-press";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { removeChatTabAtom } from "@/atoms/tabs/tabs.atom";
|
|
||||||
import {
|
import {
|
||||||
deleteThread,
|
deleteThread,
|
||||||
fetchThreads,
|
fetchThreads,
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,10 @@ import { sidebarSelectedDocumentsAtom } from "@/atoms/chat/mentioned-documents.a
|
||||||
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
|
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
|
||||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||||
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
||||||
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
|
||||||
import { expandedFolderIdsAtom } from "@/atoms/documents/folder.atoms";
|
import { expandedFolderIdsAtom } from "@/atoms/documents/folder.atoms";
|
||||||
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
|
|
||||||
import { agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms";
|
import { agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms";
|
||||||
|
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||||
|
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
|
||||||
import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog";
|
import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog";
|
||||||
import type { DocumentNodeDoc } from "@/components/documents/DocumentNode";
|
import type { DocumentNodeDoc } from "@/components/documents/DocumentNode";
|
||||||
import type { FolderDisplay } from "@/components/documents/FolderNode";
|
import type { FolderDisplay } from "@/components/documents/FolderNode";
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,12 @@ 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 h-12 items-stretch shrink-0 border-b border-border/35 bg-main-panel", className)}>
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-12 items-stretch shrink-0 border-b border-border/35 bg-main-panel",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
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"
|
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"
|
||||||
|
|
|
||||||
|
|
@ -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 "@/components/shared/image-config-dialog";
|
|
||||||
import { ModelConfigDialog } from "@/components/shared/model-config-dialog";
|
|
||||||
import { ModelSelector } from "./model-selector";
|
import { ModelSelector } from "./model-selector";
|
||||||
|
|
||||||
interface ChatHeaderProps {
|
interface ChatHeaderProps {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useSetAtom } from "jotai";
|
||||||
import {
|
import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Check,
|
Check,
|
||||||
|
|
@ -8,11 +9,10 @@ import {
|
||||||
List,
|
List,
|
||||||
Minimize2,
|
Minimize2,
|
||||||
PenLine,
|
PenLine,
|
||||||
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
Zap,
|
Zap,
|
||||||
Plus,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useSetAtom } from "jotai";
|
|
||||||
import {
|
import {
|
||||||
forwardRef,
|
forwardRef,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
|
@ -53,19 +53,71 @@ const ICONS: Record<string, React.ReactNode> = {
|
||||||
zap: <Zap className="size-3.5" />,
|
zap: <Zap className="size-3.5" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_ACTIONS: { name: string; prompt: string; mode: "transform" | "explore"; icon: string }[] = [
|
const DEFAULT_ACTIONS: {
|
||||||
{ name: "Fix grammar", prompt: "Fix the grammar and spelling in the following text. Return only the corrected text, nothing else.\n\n{selection}", mode: "transform", icon: "check" },
|
name: string;
|
||||||
{ name: "Make shorter", prompt: "Make the following text more concise while preserving its meaning. Return only the shortened text, nothing else.\n\n{selection}", mode: "transform", icon: "minimize" },
|
prompt: string;
|
||||||
{ name: "Translate", prompt: "Translate the following text to English. If it is already in English, translate it to French. Return only the translation, nothing else.\n\n{selection}", mode: "transform", icon: "languages" },
|
mode: "transform" | "explore";
|
||||||
{ name: "Rewrite", prompt: "Rewrite the following text to improve clarity and readability. Return only the rewritten text, nothing else.\n\n{selection}", mode: "transform", icon: "pen-line" },
|
icon: string;
|
||||||
{ name: "Summarize", prompt: "Summarize the following text concisely. Return only the summary, nothing else.\n\n{selection}", mode: "transform", icon: "list" },
|
}[] = [
|
||||||
{ name: "Explain", prompt: "Explain the following text in simple terms:\n\n{selection}", mode: "explore", icon: "book-open" },
|
{
|
||||||
{ name: "Ask my knowledge base", prompt: "Search my knowledge base for information related to:\n\n{selection}", mode: "explore", icon: "search" },
|
name: "Fix grammar",
|
||||||
{ name: "Look up on the web", prompt: "Search the web for information about:\n\n{selection}", mode: "explore", icon: "globe" },
|
prompt:
|
||||||
|
"Fix the grammar and spelling in the following text. Return only the corrected text, nothing else.\n\n{selection}",
|
||||||
|
mode: "transform",
|
||||||
|
icon: "check",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Make shorter",
|
||||||
|
prompt:
|
||||||
|
"Make the following text more concise while preserving its meaning. Return only the shortened text, nothing else.\n\n{selection}",
|
||||||
|
mode: "transform",
|
||||||
|
icon: "minimize",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Translate",
|
||||||
|
prompt:
|
||||||
|
"Translate the following text to English. If it is already in English, translate it to French. Return only the translation, nothing else.\n\n{selection}",
|
||||||
|
mode: "transform",
|
||||||
|
icon: "languages",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Rewrite",
|
||||||
|
prompt:
|
||||||
|
"Rewrite the following text to improve clarity and readability. Return only the rewritten text, nothing else.\n\n{selection}",
|
||||||
|
mode: "transform",
|
||||||
|
icon: "pen-line",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Summarize",
|
||||||
|
prompt:
|
||||||
|
"Summarize the following text concisely. Return only the summary, nothing else.\n\n{selection}",
|
||||||
|
mode: "transform",
|
||||||
|
icon: "list",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Explain",
|
||||||
|
prompt: "Explain the following text in simple terms:\n\n{selection}",
|
||||||
|
mode: "explore",
|
||||||
|
icon: "book-open",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Ask my knowledge base",
|
||||||
|
prompt: "Search my knowledge base for information related to:\n\n{selection}",
|
||||||
|
mode: "explore",
|
||||||
|
icon: "search",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Look up on the web",
|
||||||
|
prompt: "Search the web for information about:\n\n{selection}",
|
||||||
|
mode: "explore",
|
||||||
|
icon: "globe",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(
|
export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(function PromptPicker(
|
||||||
function PromptPicker({ onSelect, onDone, externalSearch = "", containerStyle }, ref) {
|
{ onSelect, onDone, externalSearch = "", containerStyle },
|
||||||
|
ref
|
||||||
|
) {
|
||||||
const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom);
|
const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom);
|
||||||
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||||
const [customPrompts, setCustomPrompts] = useState<PromptRead[]>([]);
|
const [customPrompts, setCustomPrompts] = useState<PromptRead[]>([]);
|
||||||
|
|
@ -74,7 +126,10 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(
|
||||||
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
|
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
promptsApiService.list().then(setCustomPrompts).catch(() => {});
|
promptsApiService
|
||||||
|
.list()
|
||||||
|
.then(setCustomPrompts)
|
||||||
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const allActions = useMemo(() => {
|
const allActions = useMemo(() => {
|
||||||
|
|
@ -89,9 +144,7 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!externalSearch) return allActions;
|
if (!externalSearch) return allActions;
|
||||||
return allActions.filter((a) =>
|
return allActions.filter((a) => a.name.toLowerCase().includes(externalSearch.toLowerCase()));
|
||||||
a.name.toLowerCase().includes(externalSearch.toLowerCase())
|
|
||||||
);
|
|
||||||
}, [allActions, externalSearch]);
|
}, [allActions, externalSearch]);
|
||||||
|
|
||||||
// Reset highlight when results change
|
// Reset highlight when results change
|
||||||
|
|
@ -174,14 +227,14 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(
|
||||||
index === highlightedIndex ? "bg-accent" : "hover:bg-accent/50"
|
index === highlightedIndex ? "bg-accent" : "hover:bg-accent/50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="text-muted-foreground">{ICONS[action.icon] ?? <Zap className="size-3.5" />}</span>
|
<span className="text-muted-foreground">
|
||||||
|
{ICONS[action.icon] ?? <Zap className="size-3.5" />}
|
||||||
|
</span>
|
||||||
<span className="truncate">{action.name}</span>
|
<span className="truncate">{action.name}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{customFiltered.length > 0 && (
|
{customFiltered.length > 0 && <div className="my-1 h-px bg-border mx-2" />}
|
||||||
<div className="my-1 h-px bg-border mx-2" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{customFiltered.map((action, i) => {
|
{customFiltered.map((action, i) => {
|
||||||
const index = defaultFiltered.length + i;
|
const index = defaultFiltered.length + i;
|
||||||
|
|
@ -200,7 +253,9 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(
|
||||||
index === highlightedIndex ? "bg-accent" : "hover:bg-accent/50"
|
index === highlightedIndex ? "bg-accent" : "hover:bg-accent/50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="text-muted-foreground"><Zap className="size-3.5" /></span>
|
<span className="text-muted-foreground">
|
||||||
|
<Zap className="size-3.5" />
|
||||||
|
</span>
|
||||||
<span className="truncate">{action.name}</span>
|
<span className="truncate">{action.name}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
@ -221,5 +276,4 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -1,24 +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,
|
|
||||||
Edit3,
|
|
||||||
Info,
|
|
||||||
Plus,
|
|
||||||
RefreshCw,
|
|
||||||
Trash2,
|
|
||||||
Wand2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import {
|
import { deleteImageGenConfigMutationAtom } from "@/atoms/image-gen-config/image-gen-config-mutation.atoms";
|
||||||
deleteImageGenConfigMutationAtom,
|
|
||||||
} 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 { 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,7 +31,6 @@ import type { ImageGenerationConfig } from "@/contracts/types/new-llm-config.typ
|
||||||
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";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ImageConfigDialog } from "@/components/shared/image-config-dialog";
|
|
||||||
|
|
||||||
interface ImageModelManagerProps {
|
interface ImageModelManagerProps {
|
||||||
searchSpaceId: number;
|
searchSpaceId: number;
|
||||||
|
|
@ -196,7 +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">
|
||||||
<p><span className="font-medium">{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length} global image {globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length === 1 ? "model" : "models"}</span> available from your administrator. Use the model selector to view and select them.</p>
|
<p>
|
||||||
|
<span className="font-medium">
|
||||||
|
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length}{" "}
|
||||||
|
global image{" "}
|
||||||
|
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length === 1
|
||||||
|
? "model"
|
||||||
|
: "models"}
|
||||||
|
</span>{" "}
|
||||||
|
available from your administrator. Use the model selector to view and select them.
|
||||||
|
</p>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
@ -400,9 +399,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
>
|
>
|
||||||
<AlertDialogContent className="select-none">
|
<AlertDialogContent className="select-none">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>
|
<AlertDialogTitle>Delete Image Model</AlertDialogTitle>
|
||||||
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>?
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,7 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { 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";
|
||||||
deleteNewLLMConfigMutationAtom,
|
|
||||||
} from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
|
|
||||||
import {
|
import {
|
||||||
globalNewLLMConfigsAtom,
|
globalNewLLMConfigsAtom,
|
||||||
newLLMConfigsAtom,
|
newLLMConfigsAtom,
|
||||||
|
|
@ -203,7 +201,12 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
||||||
<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">
|
||||||
<p><span className="font-medium">{globalConfigs.length} global {globalConfigs.length === 1 ? "model" : "models"}</span> available from your administrator. Use the model selector to view and select them.</p>
|
<p>
|
||||||
|
<span className="font-medium">
|
||||||
|
{globalConfigs.length} global {globalConfigs.length === 1 ? "model" : "models"}
|
||||||
|
</span>{" "}
|
||||||
|
available from your administrator. Use the model selector to view and select them.
|
||||||
|
</p>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
@ -433,9 +436,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
||||||
>
|
>
|
||||||
<AlertDialogContent className="select-none">
|
<AlertDialogContent className="select-none">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>
|
<AlertDialogTitle>Delete LLM Model</AlertDialogTitle>
|
||||||
Delete LLM 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>? This
|
<span className="font-semibold text-foreground">{configToDelete?.name}</span>? This
|
||||||
|
|
|
||||||
|
|
@ -217,9 +217,7 @@ export function ImageConfigDialog({
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{getSubtitle()}</p>
|
<p className="text-sm text-muted-foreground">{getSubtitle()}</p>
|
||||||
{config && mode !== "create" && (
|
{config && mode !== "create" && (
|
||||||
<p className="text-xs font-mono text-muted-foreground/70">
|
<p className="text-xs font-mono text-muted-foreground/70">{config.model_name}</p>
|
||||||
{config.model_name}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -294,9 +292,7 @@ export function ImageConfigDialog({
|
||||||
<Input
|
<Input
|
||||||
placeholder="Optional description"
|
placeholder="Optional description"
|
||||||
value={formData.description}
|
value={formData.description}
|
||||||
onChange={(e) =>
|
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
|
||||||
setFormData((p) => ({ ...p, description: e.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -337,17 +333,12 @@ export function ImageConfigDialog({
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent className="w-full p-0" align="start">
|
||||||
className="w-full p-0"
|
|
||||||
align="start"
|
|
||||||
>
|
|
||||||
<Command className="bg-transparent">
|
<Command className="bg-transparent">
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Search or type model..."
|
placeholder="Search or type model..."
|
||||||
value={formData.model_name}
|
value={formData.model_name}
|
||||||
onValueChange={(val) =>
|
onValueChange={(val) => setFormData((p) => ({ ...p, model_name: val }))}
|
||||||
setFormData((p) => ({ ...p, model_name: val }))
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>
|
<CommandEmpty>
|
||||||
|
|
@ -368,9 +359,7 @@ export function ImageConfigDialog({
|
||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 h-4 w-4",
|
"mr-2 h-4 w-4",
|
||||||
formData.model_name === m.value
|
formData.model_name === m.value ? "opacity-100" : "opacity-0"
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0"
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span className="font-mono text-sm">{m.value}</span>
|
<span className="font-mono text-sm">{m.value}</span>
|
||||||
|
|
@ -388,9 +377,7 @@ export function ImageConfigDialog({
|
||||||
<Input
|
<Input
|
||||||
placeholder="e.g., dall-e-3"
|
placeholder="e.g., dall-e-3"
|
||||||
value={formData.model_name}
|
value={formData.model_name}
|
||||||
onChange={(e) =>
|
onChange={(e) => setFormData((p) => ({ ...p, model_name: e.target.value }))}
|
||||||
setFormData((p) => ({ ...p, model_name: e.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -420,9 +407,7 @@ export function ImageConfigDialog({
|
||||||
<Input
|
<Input
|
||||||
placeholder="2024-02-15-preview"
|
placeholder="2024-02-15-preview"
|
||||||
value={formData.api_version}
|
value={formData.api_version}
|
||||||
onChange={(e) =>
|
onChange={(e) => setFormData((p) => ({ ...p, api_version: e.target.value }))}
|
||||||
setFormData((p) => ({ ...p, api_version: e.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ 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 { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useForm, type Resolver } from "react-hook-form";
|
import { type Resolver, useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
defaultSystemInstructionsAtom,
|
defaultSystemInstructionsAtom,
|
||||||
|
|
@ -228,11 +228,7 @@ export function LLMConfigForm({
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="text-xs sm:text-sm">Custom Provider Name</FormLabel>
|
<FormLabel className="text-xs sm:text-sm">Custom Provider Name</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input placeholder="my-custom-provider" {...field} value={field.value ?? ""} />
|
||||||
placeholder="my-custom-provider"
|
|
||||||
{...field}
|
|
||||||
value={field.value ?? ""}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
|
||||||
|
|
@ -167,9 +167,7 @@ export function ModelConfigDialog({
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{getSubtitle()}</p>
|
<p className="text-sm text-muted-foreground">{getSubtitle()}</p>
|
||||||
{config && mode !== "create" && (
|
{config && mode !== "create" && (
|
||||||
<p className="text-xs font-mono text-muted-foreground/70">
|
<p className="text-xs font-mono text-muted-foreground/70">{config.model_name}</p>
|
||||||
{config.model_name}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
export { cn } from "@/lib/utils";
|
|
||||||
export {
|
export {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
|
export { cn } from "@/lib/utils";
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import {
|
import { Code2, Database, ExternalLink, File, FileText, Globe, Newspaper } from "lucide-react";
|
||||||
FileText,
|
import * as React from "react";
|
||||||
Globe,
|
import { openSafeNavigationHref, resolveSafeNavigationHref } from "../shared/media";
|
||||||
Code2,
|
|
||||||
Newspaper,
|
|
||||||
Database,
|
|
||||||
File,
|
|
||||||
ExternalLink,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter";
|
import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter";
|
||||||
import { Citation } from "./citation";
|
import { Citation } from "./citation";
|
||||||
import type {
|
import type { CitationType, CitationVariant, SerializableCitation } from "./schema";
|
||||||
SerializableCitation,
|
|
||||||
CitationType,
|
|
||||||
CitationVariant,
|
|
||||||
} from "./schema";
|
|
||||||
import {
|
|
||||||
openSafeNavigationHref,
|
|
||||||
resolveSafeNavigationHref,
|
|
||||||
} from "../shared/media";
|
|
||||||
|
|
||||||
const TYPE_ICONS: Record<CitationType, LucideIcon> = {
|
const TYPE_ICONS: Record<CitationType, LucideIcon> = {
|
||||||
webpage: Globe,
|
webpage: Globe,
|
||||||
|
|
@ -64,7 +49,7 @@ function useHoverPopover(delay = 100) {
|
||||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||||
timeoutRef.current = setTimeout(() => setOpen(false), delay);
|
timeoutRef.current = setTimeout(() => setOpen(false), delay);
|
||||||
},
|
},
|
||||||
[delay],
|
[delay]
|
||||||
);
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
@ -94,27 +79,15 @@ export interface CitationListProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CitationList(props: CitationListProps) {
|
export function CitationList(props: CitationListProps) {
|
||||||
const {
|
const { id, citations, variant = "default", maxVisible, className, onNavigate } = props;
|
||||||
id,
|
|
||||||
citations,
|
|
||||||
variant = "default",
|
|
||||||
maxVisible,
|
|
||||||
className,
|
|
||||||
onNavigate,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const shouldTruncate =
|
const shouldTruncate = maxVisible !== undefined && citations.length > maxVisible;
|
||||||
maxVisible !== undefined && citations.length > maxVisible;
|
const visibleCitations = shouldTruncate ? citations.slice(0, maxVisible) : citations;
|
||||||
const visibleCitations = shouldTruncate
|
|
||||||
? citations.slice(0, maxVisible)
|
|
||||||
: citations;
|
|
||||||
const overflowCitations = shouldTruncate ? citations.slice(maxVisible) : [];
|
const overflowCitations = shouldTruncate ? citations.slice(maxVisible) : [];
|
||||||
const overflowCount = overflowCitations.length;
|
const overflowCount = overflowCitations.length;
|
||||||
|
|
||||||
const wrapperClass =
|
const wrapperClass =
|
||||||
variant === "inline"
|
variant === "inline" ? "flex flex-wrap items-center gap-1.5" : "flex flex-col gap-2";
|
||||||
? "flex flex-wrap items-center gap-1.5"
|
|
||||||
: "flex flex-col gap-2";
|
|
||||||
|
|
||||||
// Stacked variant: overlapping favicons with popover
|
// Stacked variant: overlapping favicons with popover
|
||||||
if (variant === "stacked") {
|
if (variant === "stacked") {
|
||||||
|
|
@ -136,12 +109,7 @@ export function CitationList(props: CitationListProps) {
|
||||||
data-slot="citation-list"
|
data-slot="citation-list"
|
||||||
>
|
>
|
||||||
{visibleCitations.map((citation) => (
|
{visibleCitations.map((citation) => (
|
||||||
<Citation
|
<Citation key={citation.id} {...citation} variant="default" onNavigate={onNavigate} />
|
||||||
key={citation.id}
|
|
||||||
{...citation}
|
|
||||||
variant="default"
|
|
||||||
onNavigate={onNavigate}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
{shouldTruncate && (
|
{shouldTruncate && (
|
||||||
<OverflowIndicator
|
<OverflowIndicator
|
||||||
|
|
@ -162,12 +130,7 @@ export function CitationList(props: CitationListProps) {
|
||||||
data-slot="citation-list"
|
data-slot="citation-list"
|
||||||
>
|
>
|
||||||
{visibleCitations.map((citation) => (
|
{visibleCitations.map((citation) => (
|
||||||
<Citation
|
<Citation key={citation.id} {...citation} variant={variant} onNavigate={onNavigate} />
|
||||||
key={citation.id}
|
|
||||||
{...citation}
|
|
||||||
variant={variant}
|
|
||||||
onNavigate={onNavigate}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
{shouldTruncate && (
|
{shouldTruncate && (
|
||||||
<OverflowIndicator
|
<OverflowIndicator
|
||||||
|
|
@ -188,12 +151,7 @@ interface OverflowIndicatorProps {
|
||||||
onNavigate?: (href: string, citation: SerializableCitation) => void;
|
onNavigate?: (href: string, citation: SerializableCitation) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function OverflowIndicator({
|
function OverflowIndicator({ citations, count, variant, onNavigate }: OverflowIndicatorProps) {
|
||||||
citations,
|
|
||||||
count,
|
|
||||||
variant,
|
|
||||||
onNavigate,
|
|
||||||
}: OverflowIndicatorProps) {
|
|
||||||
const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover();
|
const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover();
|
||||||
|
|
||||||
const handleClick = (citation: SerializableCitation) => {
|
const handleClick = (citation: SerializableCitation) => {
|
||||||
|
|
@ -209,11 +167,7 @@ function OverflowIndicator({
|
||||||
const popoverContent = (
|
const popoverContent = (
|
||||||
<div className="flex max-h-72 flex-col overflow-y-auto">
|
<div className="flex max-h-72 flex-col overflow-y-auto">
|
||||||
{citations.map((citation) => (
|
{citations.map((citation) => (
|
||||||
<OverflowItem
|
<OverflowItem key={citation.id} citation={citation} onClick={() => handleClick(citation)} />
|
||||||
key={citation.id}
|
|
||||||
citation={citation}
|
|
||||||
onClick={() => handleClick(citation)}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -231,7 +185,7 @@ function OverflowIndicator({
|
||||||
"bg-muted/60 text-sm tabular-nums",
|
"bg-muted/60 text-sm tabular-nums",
|
||||||
"transition-colors duration-150",
|
"transition-colors duration-150",
|
||||||
"hover:bg-muted",
|
"hover:bg-muted",
|
||||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none",
|
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="text-muted-foreground">+{count} more</span>
|
<span className="text-muted-foreground">+{count} more</span>
|
||||||
|
|
@ -264,12 +218,10 @@ function OverflowIndicator({
|
||||||
"border-border bg-card border border-dashed",
|
"border-border bg-card border border-dashed",
|
||||||
"transition-colors duration-150",
|
"transition-colors duration-150",
|
||||||
"hover:border-foreground/25 hover:bg-muted/50",
|
"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",
|
"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">
|
<span className="text-muted-foreground text-sm tabular-nums">+{count} more sources</span>
|
||||||
+{count} more sources
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
|
|
@ -311,18 +263,13 @@ function OverflowItem({ citation, onClick }: OverflowItemProps) {
|
||||||
className="bg-muted size-4 shrink-0 rounded object-cover"
|
className="bg-muted size-4 shrink-0 rounded object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<TypeIcon
|
<TypeIcon className="text-muted-foreground size-4 shrink-0" aria-hidden="true" />
|
||||||
className="text-muted-foreground size-4 shrink-0"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
<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">
|
<p className="group-hover:decoration-foreground/30 truncate text-sm font-medium group-hover:underline group-hover:underline-offset-2">
|
||||||
{citation.title}
|
{citation.title}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground truncate text-xs">
|
<p className="text-muted-foreground truncate text-xs">{citation.domain}</p>
|
||||||
{citation.domain}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<ExternalLink className="text-muted-foreground mt-0.5 size-3.5 shrink-0 self-start opacity-0 transition-opacity group-hover:opacity-100" />
|
<ExternalLink className="text-muted-foreground mt-0.5 size-3.5 shrink-0 self-start opacity-0 transition-opacity group-hover:opacity-100" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -336,20 +283,9 @@ interface StackedCitationsProps {
|
||||||
onNavigate?: (href: string, citation: SerializableCitation) => void;
|
onNavigate?: (href: string, citation: SerializableCitation) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StackedCitations({
|
function StackedCitations({ id, citations, className, onNavigate }: StackedCitationsProps) {
|
||||||
id,
|
const { open, setOpen, containerRef, handleMouseEnter, handleMouseLeave, handleBlur } =
|
||||||
citations,
|
useHoverPopover();
|
||||||
className,
|
|
||||||
onNavigate,
|
|
||||||
}: StackedCitationsProps) {
|
|
||||||
const {
|
|
||||||
open,
|
|
||||||
setOpen,
|
|
||||||
containerRef,
|
|
||||||
handleMouseEnter,
|
|
||||||
handleMouseLeave,
|
|
||||||
handleBlur,
|
|
||||||
} = useHoverPopover();
|
|
||||||
const maxIcons = 4;
|
const maxIcons = 4;
|
||||||
const visibleCitations = citations.slice(0, maxIcons);
|
const visibleCitations = citations.slice(0, maxIcons);
|
||||||
const remainingCount = Math.max(0, citations.length - maxIcons);
|
const remainingCount = Math.max(0, citations.length - maxIcons);
|
||||||
|
|
@ -387,19 +323,18 @@ function StackedCitations({
|
||||||
"transition-colors duration-150",
|
"transition-colors duration-150",
|
||||||
"hover:bg-muted/70",
|
"hover:bg-muted/70",
|
||||||
"focus-visible:ring-ring focus-visible:ring-2",
|
"focus-visible:ring-ring focus-visible:ring-2",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{visibleCitations.map((citation, index) => {
|
{visibleCitations.map((citation, index) => {
|
||||||
const TypeIcon =
|
const TypeIcon = TYPE_ICONS[citation.type ?? "webpage"] ?? Globe;
|
||||||
TYPE_ICONS[citation.type ?? "webpage"] ?? Globe;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={citation.id}
|
key={citation.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-border bg-background dark:border-foreground/20 relative flex size-6 items-center justify-center rounded-full border shadow-xs",
|
"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",
|
index > 0 && "-ml-2"
|
||||||
)}
|
)}
|
||||||
style={{ zIndex: maxIcons - index }}
|
style={{ zIndex: maxIcons - index }}
|
||||||
>
|
>
|
||||||
|
|
@ -414,10 +349,7 @@ function StackedCitations({
|
||||||
className="size-4.5 rounded-full object-cover"
|
className="size-4.5 rounded-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<TypeIcon
|
<TypeIcon className="text-muted-foreground size-3" aria-hidden="true" />
|
||||||
className="text-muted-foreground size-3"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import {
|
import { Code2, Database, ExternalLink, File, FileText, Globe, Newspaper } from "lucide-react";
|
||||||
FileText,
|
import * as React from "react";
|
||||||
Globe,
|
|
||||||
Code2,
|
|
||||||
Newspaper,
|
|
||||||
Database,
|
|
||||||
File,
|
|
||||||
ExternalLink,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter";
|
|
||||||
|
|
||||||
import { openSafeNavigationHref, sanitizeHref } from "../shared/media";
|
import { openSafeNavigationHref, sanitizeHref } from "../shared/media";
|
||||||
import type {
|
import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter";
|
||||||
SerializableCitation,
|
import type { CitationType, CitationVariant, SerializableCitation } from "./schema";
|
||||||
CitationType,
|
|
||||||
CitationVariant,
|
|
||||||
} from "./schema";
|
|
||||||
|
|
||||||
const FALLBACK_LOCALE = "en-US";
|
const FALLBACK_LOCALE = "en-US";
|
||||||
|
|
||||||
|
|
@ -161,7 +148,7 @@ export function Citation(props: CitationProps) {
|
||||||
"transition-colors duration-150",
|
"transition-colors duration-150",
|
||||||
"hover:bg-muted",
|
"hover:bg-muted",
|
||||||
"focus-visible:ring-ring focus-visible:ring-2",
|
"focus-visible:ring-ring focus-visible:ring-2",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{iconElement}
|
{iconElement}
|
||||||
|
|
@ -213,7 +200,7 @@ export function Citation(props: CitationProps) {
|
||||||
"cursor-pointer",
|
"cursor-pointer",
|
||||||
"hover:border-foreground/25",
|
"hover:border-foreground/25",
|
||||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||||
],
|
]
|
||||||
)}
|
)}
|
||||||
onClick={sanitizedHref ? handleClick : undefined}
|
onClick={sanitizedHref ? handleClick : undefined}
|
||||||
role={sanitizedHref ? "link" : undefined}
|
role={sanitizedHref ? "link" : undefined}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
export { Citation } from "./citation";
|
|
||||||
export type { CitationProps } from "./citation";
|
export type { CitationProps } from "./citation";
|
||||||
export { CitationList } from "./citation-list";
|
export { Citation } from "./citation";
|
||||||
export type { CitationListProps } from "./citation-list";
|
export type { CitationListProps } from "./citation-list";
|
||||||
|
export { CitationList } from "./citation-list";
|
||||||
export type {
|
export type {
|
||||||
SerializableCitation,
|
|
||||||
CitationType,
|
CitationType,
|
||||||
CitationVariant,
|
CitationVariant,
|
||||||
|
SerializableCitation,
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import { ToolUIIdSchema, ToolUIReceiptSchema, ToolUIRoleSchema } from "../shared/schema";
|
||||||
ToolUIIdSchema,
|
|
||||||
ToolUIReceiptSchema,
|
|
||||||
ToolUIRoleSchema,
|
|
||||||
} from "../shared/schema";
|
|
||||||
|
|
||||||
export const CitationTypeSchema = z.enum([
|
export const CitationTypeSchema = z.enum([
|
||||||
"webpage",
|
"webpage",
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ export {
|
||||||
export { GeneratePodcastToolUI } from "./generate-podcast";
|
export { GeneratePodcastToolUI } from "./generate-podcast";
|
||||||
export { GenerateReportToolUI } from "./generate-report";
|
export { GenerateReportToolUI } from "./generate-report";
|
||||||
export { CreateGoogleDriveFileToolUI, DeleteGoogleDriveFileToolUI } from "./google-drive";
|
export { CreateGoogleDriveFileToolUI, DeleteGoogleDriveFileToolUI } from "./google-drive";
|
||||||
export { CreateOneDriveFileToolUI, DeleteOneDriveFileToolUI } from "./onedrive";
|
|
||||||
export {
|
export {
|
||||||
Image,
|
Image,
|
||||||
ImageErrorBoundary,
|
ImageErrorBoundary,
|
||||||
|
|
@ -33,6 +32,7 @@ export {
|
||||||
UpdateLinearIssueToolUI,
|
UpdateLinearIssueToolUI,
|
||||||
} from "./linear";
|
} from "./linear";
|
||||||
export { CreateNotionPageToolUI, DeleteNotionPageToolUI, UpdateNotionPageToolUI } from "./notion";
|
export { CreateNotionPageToolUI, DeleteNotionPageToolUI, UpdateNotionPageToolUI } from "./notion";
|
||||||
|
export { CreateOneDriveFileToolUI, DeleteOneDriveFileToolUI } from "./onedrive";
|
||||||
export {
|
export {
|
||||||
Plan,
|
Plan,
|
||||||
PlanErrorBoundary,
|
PlanErrorBoundary,
|
||||||
|
|
|
||||||
|
|
@ -270,9 +270,7 @@ function ApprovalCard({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs font-medium text-muted-foreground">
|
<p className="text-xs font-medium text-muted-foreground">File Type</p>
|
||||||
File Type
|
|
||||||
</p>
|
|
||||||
<Select value="docx" disabled>
|
<Select value="docx" disabled>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|
@ -315,11 +313,25 @@ function ApprovalCard({
|
||||||
<div className="mx-5 h-px bg-border/50" />
|
<div className="mx-5 h-px bg-border/50" />
|
||||||
<div className="px-5 pt-3">
|
<div className="px-5 pt-3">
|
||||||
{(pendingEdits?.name ?? args.name) != null && (
|
{(pendingEdits?.name ?? args.name) != null && (
|
||||||
<p className="text-sm font-medium text-foreground">{String(pendingEdits?.name ?? args.name)}</p>
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
{String(pendingEdits?.name ?? args.name)}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
{(pendingEdits?.content ?? args.content) != null && (
|
{(pendingEdits?.content ?? args.content) != null && (
|
||||||
<div className="mt-2 max-h-[7rem] overflow-hidden text-sm" style={{ maskImage: "linear-gradient(to bottom, black 50%, transparent 100%)", WebkitMaskImage: "linear-gradient(to bottom, black 50%, transparent 100%)" }}>
|
<div
|
||||||
<PlateEditor markdown={String(pendingEdits?.content ?? args.content)} readOnly preset="readonly" editorVariant="none" className="h-auto [&_[data-slate-editor]]:!min-h-0 [&_[data-slate-editor]>*:first-child]:!mt-0" />
|
className="mt-2 max-h-[7rem] overflow-hidden text-sm"
|
||||||
|
style={{
|
||||||
|
maskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
|
||||||
|
WebkitMaskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlateEditor
|
||||||
|
markdown={String(pendingEdits?.content ?? args.content)}
|
||||||
|
readOnly
|
||||||
|
preset="readonly"
|
||||||
|
editorVariant="none"
|
||||||
|
className="h-auto [&_[data-slate-editor]]:!min-h-0 [&_[data-slate-editor]>*:first-child]:!mt-0"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -329,12 +341,26 @@ function ApprovalCard({
|
||||||
<div className="mx-5 h-px bg-border/50" />
|
<div className="mx-5 h-px bg-border/50" />
|
||||||
<div className="px-5 py-4 flex items-center gap-2 select-none">
|
<div className="px-5 py-4 flex items-center gap-2 select-none">
|
||||||
{allowedDecisions.includes("approve") && (
|
{allowedDecisions.includes("approve") && (
|
||||||
<Button size="sm" className="rounded-lg gap-1.5" onClick={handleApprove} disabled={!canApprove || isPanelOpen}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="rounded-lg gap-1.5"
|
||||||
|
onClick={handleApprove}
|
||||||
|
disabled={!canApprove || isPanelOpen}
|
||||||
|
>
|
||||||
Approve <CornerDownLeftIcon className="size-3 opacity-60" />
|
Approve <CornerDownLeftIcon className="size-3 opacity-60" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{allowedDecisions.includes("reject") && (
|
{allowedDecisions.includes("reject") && (
|
||||||
<Button size="sm" variant="ghost" className="rounded-lg text-muted-foreground" disabled={isPanelOpen} onClick={() => { setRejected(); onDecision({ type: "reject", message: "User rejected the action." }); }}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="rounded-lg text-muted-foreground"
|
||||||
|
disabled={isPanelOpen}
|
||||||
|
onClick={() => {
|
||||||
|
setRejected();
|
||||||
|
onDecision({ type: "reject", message: "User rejected the action." });
|
||||||
|
}}
|
||||||
|
>
|
||||||
Reject
|
Reject
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -352,7 +378,9 @@ function ErrorCard({ result }: { result: ErrorResult }) {
|
||||||
<p className="text-sm font-semibold text-destructive">Failed to create OneDrive file</p>
|
<p className="text-sm font-semibold text-destructive">Failed to create OneDrive file</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-5 h-px bg-border/50" />
|
<div className="mx-5 h-px bg-border/50" />
|
||||||
<div className="px-5 py-4"><p className="text-sm text-muted-foreground">{result.message}</p></div>
|
<div className="px-5 py-4">
|
||||||
|
<p className="text-sm text-muted-foreground">{result.message}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -364,7 +392,9 @@ function AuthErrorCard({ result }: { result: AuthErrorResult }) {
|
||||||
<p className="text-sm font-semibold text-destructive">OneDrive authentication expired</p>
|
<p className="text-sm font-semibold text-destructive">OneDrive authentication expired</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-5 h-px bg-border/50" />
|
<div className="mx-5 h-px bg-border/50" />
|
||||||
<div className="px-5 py-4"><p className="text-sm text-muted-foreground">{result.message}</p></div>
|
<div className="px-5 py-4">
|
||||||
|
<p className="text-sm text-muted-foreground">{result.message}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -373,7 +403,9 @@ function SuccessCard({ result }: { result: SuccessResult }) {
|
||||||
return (
|
return (
|
||||||
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
||||||
<div className="px-5 pt-5 pb-4">
|
<div className="px-5 pt-5 pb-4">
|
||||||
<p className="text-sm font-semibold text-foreground">{result.message || "OneDrive file created successfully"}</p>
|
<p className="text-sm font-semibold text-foreground">
|
||||||
|
{result.message || "OneDrive file created successfully"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-5 h-px bg-border/50" />
|
<div className="mx-5 h-px bg-border/50" />
|
||||||
<div className="px-5 py-4 space-y-2 text-xs">
|
<div className="px-5 py-4 space-y-2 text-xs">
|
||||||
|
|
@ -383,7 +415,14 @@ function SuccessCard({ result }: { result: SuccessResult }) {
|
||||||
</div>
|
</div>
|
||||||
{result.web_url && (
|
{result.web_url && (
|
||||||
<div>
|
<div>
|
||||||
<a href={result.web_url} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">Open in OneDrive</a>
|
<a
|
||||||
|
href={result.web_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Open in OneDrive
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -391,12 +430,31 @@ function SuccessCard({ result }: { result: SuccessResult }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CreateOneDriveFileToolUI = ({ args, result }: ToolCallMessagePartProps<{ name: string; content?: string }, CreateOneDriveFileResult>) => {
|
export const CreateOneDriveFileToolUI = ({
|
||||||
|
args,
|
||||||
|
result,
|
||||||
|
}: ToolCallMessagePartProps<{ name: string; content?: string }, CreateOneDriveFileResult>) => {
|
||||||
if (!result) return null;
|
if (!result) return null;
|
||||||
if (isInterruptResult(result)) {
|
if (isInterruptResult(result)) {
|
||||||
return <ApprovalCard args={args} interruptData={result} onDecision={(decision) => { window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })); }} />;
|
return (
|
||||||
|
<ApprovalCard
|
||||||
|
args={args}
|
||||||
|
interruptData={result}
|
||||||
|
onDecision={(decision) => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (typeof result === "object" && result !== null && "status" in result && (result as { status: string }).status === "rejected") return null;
|
if (
|
||||||
|
typeof result === "object" &&
|
||||||
|
result !== null &&
|
||||||
|
"status" in result &&
|
||||||
|
(result as { status: string }).status === "rejected"
|
||||||
|
)
|
||||||
|
return null;
|
||||||
if (isAuthErrorResult(result)) return <AuthErrorCard result={result} />;
|
if (isAuthErrorResult(result)) return <AuthErrorCard result={result} />;
|
||||||
if (isErrorResult(result)) return <ErrorCard result={result} />;
|
if (isErrorResult(result)) return <ErrorCard result={result} />;
|
||||||
return <SuccessCard result={result as SuccessResult} />;
|
return <SuccessCard result={result as SuccessResult} />;
|
||||||
|
|
|
||||||
|
|
@ -31,29 +31,76 @@ interface InterruptResult {
|
||||||
context?: { account?: OneDriveAccount; file?: OneDriveFile; error?: string };
|
context?: { account?: OneDriveAccount; file?: OneDriveFile; error?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SuccessResult { status: "success"; file_id: string; message?: string; deleted_from_kb?: boolean }
|
interface SuccessResult {
|
||||||
interface ErrorResult { status: "error"; message: string }
|
status: "success";
|
||||||
interface NotFoundResult { status: "not_found"; message: string }
|
file_id: string;
|
||||||
interface AuthErrorResult { status: "auth_error"; message: string; connector_type?: string }
|
message?: string;
|
||||||
|
deleted_from_kb?: boolean;
|
||||||
|
}
|
||||||
|
interface ErrorResult {
|
||||||
|
status: "error";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
interface NotFoundResult {
|
||||||
|
status: "not_found";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
interface AuthErrorResult {
|
||||||
|
status: "auth_error";
|
||||||
|
message: string;
|
||||||
|
connector_type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
type DeleteOneDriveFileResult = InterruptResult | SuccessResult | ErrorResult | NotFoundResult | AuthErrorResult;
|
type DeleteOneDriveFileResult =
|
||||||
|
| InterruptResult
|
||||||
|
| SuccessResult
|
||||||
|
| ErrorResult
|
||||||
|
| NotFoundResult
|
||||||
|
| AuthErrorResult;
|
||||||
|
|
||||||
function isInterruptResult(result: unknown): result is InterruptResult {
|
function isInterruptResult(result: unknown): result is InterruptResult {
|
||||||
return typeof result === "object" && result !== null && "__interrupt__" in result && (result as InterruptResult).__interrupt__ === true;
|
return (
|
||||||
|
typeof result === "object" &&
|
||||||
|
result !== null &&
|
||||||
|
"__interrupt__" in result &&
|
||||||
|
(result as InterruptResult).__interrupt__ === true
|
||||||
|
);
|
||||||
}
|
}
|
||||||
function isErrorResult(result: unknown): result is ErrorResult {
|
function isErrorResult(result: unknown): result is ErrorResult {
|
||||||
return typeof result === "object" && result !== null && "status" in result && (result as ErrorResult).status === "error";
|
return (
|
||||||
|
typeof result === "object" &&
|
||||||
|
result !== null &&
|
||||||
|
"status" in result &&
|
||||||
|
(result as ErrorResult).status === "error"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
function isNotFoundResult(result: unknown): result is NotFoundResult {
|
function isNotFoundResult(result: unknown): result is NotFoundResult {
|
||||||
return typeof result === "object" && result !== null && "status" in result && (result as NotFoundResult).status === "not_found";
|
return (
|
||||||
|
typeof result === "object" &&
|
||||||
|
result !== null &&
|
||||||
|
"status" in result &&
|
||||||
|
(result as NotFoundResult).status === "not_found"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
function isAuthErrorResult(result: unknown): result is AuthErrorResult {
|
function isAuthErrorResult(result: unknown): result is AuthErrorResult {
|
||||||
return typeof result === "object" && result !== null && "status" in result && (result as AuthErrorResult).status === "auth_error";
|
return (
|
||||||
|
typeof result === "object" &&
|
||||||
|
result !== null &&
|
||||||
|
"status" in result &&
|
||||||
|
(result as AuthErrorResult).status === "auth_error"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ApprovalCard({ interruptData, onDecision }: {
|
function ApprovalCard({
|
||||||
|
interruptData,
|
||||||
|
onDecision,
|
||||||
|
}: {
|
||||||
interruptData: InterruptResult;
|
interruptData: InterruptResult;
|
||||||
onDecision: (decision: { type: "approve" | "reject"; message?: string; edited_action?: { name: string; args: Record<string, unknown> } }) => void;
|
onDecision: (decision: {
|
||||||
|
type: "approve" | "reject";
|
||||||
|
message?: string;
|
||||||
|
edited_action?: { name: string; args: Record<string, unknown> };
|
||||||
|
}) => void;
|
||||||
}) {
|
}) {
|
||||||
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
||||||
const [deleteFromKb, setDeleteFromKb] = useState(false);
|
const [deleteFromKb, setDeleteFromKb] = useState(false);
|
||||||
|
|
@ -87,7 +134,11 @@ function ApprovalCard({ interruptData, onDecision }: {
|
||||||
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
|
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold text-foreground">
|
<p className="text-sm font-semibold text-foreground">
|
||||||
{phase === "rejected" ? "OneDrive File Deletion Rejected" : phase === "processing" || phase === "complete" ? "OneDrive File Deletion Approved" : "Delete OneDrive File"}
|
{phase === "rejected"
|
||||||
|
? "OneDrive File Deletion Rejected"
|
||||||
|
: phase === "processing" || phase === "complete"
|
||||||
|
? "OneDrive File Deletion Approved"
|
||||||
|
: "Delete OneDrive File"}
|
||||||
</p>
|
</p>
|
||||||
{phase === "processing" ? (
|
{phase === "processing" ? (
|
||||||
<TextShimmerLoader text="Trashing file" size="sm" />
|
<TextShimmerLoader text="Trashing file" size="sm" />
|
||||||
|
|
@ -96,7 +147,9 @@ function ApprovalCard({ interruptData, onDecision }: {
|
||||||
) : phase === "rejected" ? (
|
) : phase === "rejected" ? (
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">File deletion was cancelled</p>
|
<p className="text-xs text-muted-foreground mt-0.5">File deletion was cancelled</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">Requires your approval to proceed</p>
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Requires your approval to proceed
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -112,7 +165,9 @@ function ApprovalCard({ interruptData, onDecision }: {
|
||||||
{account && (
|
{account && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs font-medium text-muted-foreground">OneDrive Account</p>
|
<p className="text-xs font-medium text-muted-foreground">OneDrive Account</p>
|
||||||
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm">{account.name}</div>
|
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm">
|
||||||
|
{account.name}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{file && (
|
{file && (
|
||||||
|
|
@ -121,7 +176,14 @@ function ApprovalCard({ interruptData, onDecision }: {
|
||||||
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm space-y-0.5">
|
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm space-y-0.5">
|
||||||
<div className="font-medium">{file.name}</div>
|
<div className="font-medium">{file.name}</div>
|
||||||
{file.web_url && (
|
{file.web_url && (
|
||||||
<a href={file.web_url} target="_blank" rel="noopener noreferrer" className="text-xs text-primary hover:underline">Open in OneDrive</a>
|
<a
|
||||||
|
href={file.web_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Open in OneDrive
|
||||||
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -136,12 +198,21 @@ function ApprovalCard({ interruptData, onDecision }: {
|
||||||
<>
|
<>
|
||||||
<div className="mx-5 h-px bg-border/50" />
|
<div className="mx-5 h-px bg-border/50" />
|
||||||
<div className="px-5 py-4 space-y-3 select-none">
|
<div className="px-5 py-4 space-y-3 select-none">
|
||||||
<p className="text-xs text-muted-foreground">The file will be moved to the OneDrive recycle bin. You can restore it within 93 days.</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
The file will be moved to the OneDrive recycle bin. You can restore it within 93 days.
|
||||||
|
</p>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<Checkbox id="od-delete-from-kb" checked={deleteFromKb} onCheckedChange={(v) => setDeleteFromKb(v === true)} className="shrink-0" />
|
<Checkbox
|
||||||
|
id="od-delete-from-kb"
|
||||||
|
checked={deleteFromKb}
|
||||||
|
onCheckedChange={(v) => setDeleteFromKb(v === true)}
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
<label htmlFor="od-delete-from-kb" className="flex-1 cursor-pointer">
|
<label htmlFor="od-delete-from-kb" className="flex-1 cursor-pointer">
|
||||||
<span className="text-sm text-foreground">Also remove from knowledge base</span>
|
<span className="text-sm text-foreground">Also remove from knowledge base</span>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">This will permanently delete the file from your knowledge base</p>
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
This will permanently delete the file from your knowledge base
|
||||||
|
</p>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -152,8 +223,20 @@ function ApprovalCard({ interruptData, onDecision }: {
|
||||||
<>
|
<>
|
||||||
<div className="mx-5 h-px bg-border/50" />
|
<div className="mx-5 h-px bg-border/50" />
|
||||||
<div className="px-5 py-4 flex items-center gap-2 select-none">
|
<div className="px-5 py-4 flex items-center gap-2 select-none">
|
||||||
<Button size="sm" className="rounded-lg gap-1.5" onClick={handleApprove}>Approve <CornerDownLeftIcon className="size-3 opacity-60" /></Button>
|
<Button size="sm" className="rounded-lg gap-1.5" onClick={handleApprove}>
|
||||||
<Button size="sm" variant="ghost" className="rounded-lg text-muted-foreground" onClick={() => { setRejected(); onDecision({ type: "reject", message: "User rejected the action." }); }}>Reject</Button>
|
Approve <CornerDownLeftIcon className="size-3 opacity-60" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="rounded-lg text-muted-foreground"
|
||||||
|
onClick={() => {
|
||||||
|
setRejected();
|
||||||
|
onDecision({ type: "reject", message: "User rejected the action." });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -164,9 +247,13 @@ function ApprovalCard({ interruptData, onDecision }: {
|
||||||
function ErrorCard({ result }: { result: ErrorResult }) {
|
function ErrorCard({ result }: { result: ErrorResult }) {
|
||||||
return (
|
return (
|
||||||
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
||||||
<div className="px-5 pt-5 pb-4"><p className="text-sm font-semibold text-destructive">Failed to delete file</p></div>
|
<div className="px-5 pt-5 pb-4">
|
||||||
|
<p className="text-sm font-semibold text-destructive">Failed to delete file</p>
|
||||||
|
</div>
|
||||||
<div className="mx-5 h-px bg-border/50" />
|
<div className="mx-5 h-px bg-border/50" />
|
||||||
<div className="px-5 py-4"><p className="text-sm text-muted-foreground">{result.message}</p></div>
|
<div className="px-5 py-4">
|
||||||
|
<p className="text-sm text-muted-foreground">{result.message}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -185,9 +272,13 @@ function NotFoundCard({ result }: { result: NotFoundResult }) {
|
||||||
function AuthErrorCard({ result }: { result: AuthErrorResult }) {
|
function AuthErrorCard({ result }: { result: AuthErrorResult }) {
|
||||||
return (
|
return (
|
||||||
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
||||||
<div className="px-5 pt-5 pb-4"><p className="text-sm font-semibold text-destructive">OneDrive authentication expired</p></div>
|
<div className="px-5 pt-5 pb-4">
|
||||||
|
<p className="text-sm font-semibold text-destructive">OneDrive authentication expired</p>
|
||||||
|
</div>
|
||||||
<div className="mx-5 h-px bg-border/50" />
|
<div className="mx-5 h-px bg-border/50" />
|
||||||
<div className="px-5 py-4"><p className="text-sm text-muted-foreground">{result.message}</p></div>
|
<div className="px-5 py-4">
|
||||||
|
<p className="text-sm text-muted-foreground">{result.message}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -195,23 +286,51 @@ function AuthErrorCard({ result }: { result: AuthErrorResult }) {
|
||||||
function SuccessCard({ result }: { result: SuccessResult }) {
|
function SuccessCard({ result }: { result: SuccessResult }) {
|
||||||
return (
|
return (
|
||||||
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
||||||
<div className="px-5 pt-5 pb-4"><p className="text-sm font-semibold text-foreground">{result.message || "File moved to recycle bin"}</p></div>
|
<div className="px-5 pt-5 pb-4">
|
||||||
|
<p className="text-sm font-semibold text-foreground">
|
||||||
|
{result.message || "File moved to recycle bin"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
{result.deleted_from_kb && (
|
{result.deleted_from_kb && (
|
||||||
<>
|
<>
|
||||||
<div className="mx-5 h-px bg-border/50" />
|
<div className="mx-5 h-px bg-border/50" />
|
||||||
<div className="px-5 py-4 text-xs"><span className="text-green-600 dark:text-green-500">Also removed from knowledge base</span></div>
|
<div className="px-5 py-4 text-xs">
|
||||||
|
<span className="text-green-600 dark:text-green-500">
|
||||||
|
Also removed from knowledge base
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DeleteOneDriveFileToolUI = ({ result }: ToolCallMessagePartProps<{ file_name: string; delete_from_kb?: boolean }, DeleteOneDriveFileResult>) => {
|
export const DeleteOneDriveFileToolUI = ({
|
||||||
|
result,
|
||||||
|
}: ToolCallMessagePartProps<
|
||||||
|
{ file_name: string; delete_from_kb?: boolean },
|
||||||
|
DeleteOneDriveFileResult
|
||||||
|
>) => {
|
||||||
if (!result) return null;
|
if (!result) return null;
|
||||||
if (isInterruptResult(result)) {
|
if (isInterruptResult(result)) {
|
||||||
return <ApprovalCard interruptData={result} onDecision={(decision) => { window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })); }} />;
|
return (
|
||||||
|
<ApprovalCard
|
||||||
|
interruptData={result}
|
||||||
|
onDecision={(decision) => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (typeof result === "object" && result !== null && "status" in result && (result as { status: string }).status === "rejected") return null;
|
if (
|
||||||
|
typeof result === "object" &&
|
||||||
|
result !== null &&
|
||||||
|
"status" in result &&
|
||||||
|
(result as { status: string }).status === "rejected"
|
||||||
|
)
|
||||||
|
return null;
|
||||||
if (isAuthErrorResult(result)) return <AuthErrorCard result={result} />;
|
if (isAuthErrorResult(result)) return <AuthErrorCard result={result} />;
|
||||||
if (isNotFoundResult(result)) return <NotFoundCard result={result} />;
|
if (isNotFoundResult(result)) return <NotFoundCard result={result} />;
|
||||||
if (isErrorResult(result)) return <ErrorCard result={result} />;
|
if (isErrorResult(result)) return <ErrorCard result={result} />;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
export { sanitizeHref } from "./sanitize-href";
|
|
||||||
export {
|
export {
|
||||||
resolveSafeNavigationHref,
|
|
||||||
openSafeNavigationHref,
|
openSafeNavigationHref,
|
||||||
|
resolveSafeNavigationHref,
|
||||||
} from "./safe-navigation";
|
} from "./safe-navigation";
|
||||||
|
export { sanitizeHref } from "./sanitize-href";
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { z } from "zod";
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tool UI conventions:
|
* Tool UI conventions:
|
||||||
|
|
@ -39,12 +39,7 @@ export const ToolUIRoleSchema = z.enum([
|
||||||
|
|
||||||
export type ToolUIRole = z.infer<typeof ToolUIRoleSchema>;
|
export type ToolUIRole = z.infer<typeof ToolUIRoleSchema>;
|
||||||
|
|
||||||
export const ToolUIReceiptOutcomeSchema = z.enum([
|
export const ToolUIReceiptOutcomeSchema = z.enum(["success", "partial", "failed", "cancelled"]);
|
||||||
"success",
|
|
||||||
"partial",
|
|
||||||
"failed",
|
|
||||||
"cancelled",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export type ToolUIReceiptOutcome = z.infer<typeof ToolUIReceiptOutcomeSchema>;
|
export type ToolUIReceiptOutcome = z.infer<typeof ToolUIReceiptOutcomeSchema>;
|
||||||
|
|
||||||
|
|
@ -81,9 +76,7 @@ export const ActionSchema = z.object({
|
||||||
*/
|
*/
|
||||||
sentence: z.string().optional(),
|
sentence: z.string().optional(),
|
||||||
confirmLabel: z.string().optional(),
|
confirmLabel: z.string().optional(),
|
||||||
variant: z
|
variant: z.enum(["default", "destructive", "secondary", "ghost", "outline"]).optional(),
|
||||||
.enum(["default", "destructive", "secondary", "ghost", "outline"])
|
|
||||||
.optional(),
|
|
||||||
icon: z.custom<ReactNode>().optional(),
|
icon: z.custom<ReactNode>().optional(),
|
||||||
loading: z.boolean().optional(),
|
loading: z.boolean().optional(),
|
||||||
disabled: z.boolean().optional(),
|
disabled: z.boolean().optional(),
|
||||||
|
|
@ -104,11 +97,10 @@ export const DecisionResultSchema = z.object({
|
||||||
payload: z.record(z.string(), z.unknown()).optional(),
|
payload: z.record(z.string(), z.unknown()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type DecisionResult<
|
export type DecisionResult<TPayload extends Record<string, unknown> = Record<string, unknown>> =
|
||||||
TPayload extends Record<string, unknown> = Record<string, unknown>,
|
Omit<z.infer<typeof DecisionResultSchema>, "payload"> & {
|
||||||
> = Omit<z.infer<typeof DecisionResultSchema>, "payload"> & {
|
|
||||||
payload?: TPayload;
|
payload?: TPayload;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createDecisionResult<
|
export function createDecisionResult<
|
||||||
TPayload extends Record<string, unknown> = Record<string, unknown>,
|
TPayload extends Record<string, unknown> = Record<string, unknown>,
|
||||||
|
|
@ -152,8 +144,6 @@ export const SerializableActionsConfigSchema = z.object({
|
||||||
confirmTimeout: z.number().positive().optional(),
|
confirmTimeout: z.number().positive().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type SerializableActionsConfig = z.infer<
|
export type SerializableActionsConfig = z.infer<typeof SerializableActionsConfigSchema>;
|
||||||
typeof SerializableActionsConfigSchema
|
|
||||||
>;
|
|
||||||
|
|
||||||
export type SerializableAction = z.infer<typeof SerializableActionSchema>;
|
export type SerializableAction = z.infer<typeof SerializableActionSchema>;
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ import {
|
||||||
promptCreateRequest,
|
promptCreateRequest,
|
||||||
promptDeleteResponse,
|
promptDeleteResponse,
|
||||||
promptRead,
|
promptRead,
|
||||||
promptUpdateRequest,
|
|
||||||
promptsListResponse,
|
promptsListResponse,
|
||||||
|
promptUpdateRequest,
|
||||||
} from "@/contracts/types/prompts.types";
|
} from "@/contracts/types/prompts.types";
|
||||||
import { ValidationError } from "@/lib/error";
|
import { ValidationError } from "@/lib/error";
|
||||||
import { baseApiService } from "./base-api.service";
|
import { baseApiService } from "./base-api.service";
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,7 @@ export interface ContentPartsState {
|
||||||
toolCallIndices: Map<string, number>;
|
toolCallIndices: Map<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function areThinkingStepsEqual(
|
function areThinkingStepsEqual(current: ThinkingStepData[], next: ThinkingStepData[]): boolean {
|
||||||
current: ThinkingStepData[],
|
|
||||||
next: ThinkingStepData[]
|
|
||||||
): boolean {
|
|
||||||
if (current.length !== next.length) return false;
|
if (current.length !== next.length) return false;
|
||||||
|
|
||||||
for (let i = 0; i < current.length; i += 1) {
|
for (let i = 0; i < current.length; i += 1) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue