chore: linting
Some checks failed
Obsidian Plugin Lint / lint (push) Has been cancelled

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-04-27 14:04:50 -07:00
parent f607636ba6
commit 8d50f90060
74 changed files with 1135 additions and 693 deletions

View file

@ -50,7 +50,10 @@ from app.agents.new_chat.system_prompt import (
build_configurable_system_prompt,
build_surfsense_system_prompt,
)
from app.agents.new_chat.tools.registry import build_tools_async, get_connector_gated_tools
from app.agents.new_chat.tools.registry import (
build_tools_async,
get_connector_gated_tools,
)
from app.db import ChatVisibility
from app.services.connector_service import ConnectorService
from app.utils.perf import get_perf_logger
@ -294,9 +297,7 @@ async def create_surfsense_deep_agent(
}
modified_disabled_tools = list(disabled_tools) if disabled_tools else []
modified_disabled_tools.extend(
get_connector_gated_tools(available_connectors)
)
modified_disabled_tools.extend(get_connector_gated_tools(available_connectors))
# Remove direct KB search tool; we now pre-seed a scoped filesystem via middleware.
if "search_knowledge_base" not in modified_disabled_tools:
@ -328,7 +329,8 @@ async def create_surfsense_deep_agent(
meta = getattr(t, "metadata", None) or {}
if meta.get("mcp_is_generic") and meta.get("mcp_connector_name"):
_mcp_connector_tools.setdefault(
meta["mcp_connector_name"], [],
meta["mcp_connector_name"],
[],
).append(t.name)
if _mcp_connector_tools:

View file

@ -3,12 +3,12 @@
from app.agents.new_chat.middleware.dedup_tool_calls import (
DedupHITLToolCallsMiddleware,
)
from app.agents.new_chat.middleware.filesystem import (
SurfSenseFilesystemMiddleware,
)
from app.agents.new_chat.middleware.file_intent import (
FileIntentMiddleware,
)
from app.agents.new_chat.middleware.filesystem import (
SurfSenseFilesystemMiddleware,
)
from app.agents.new_chat.middleware.knowledge_search import (
KnowledgeBaseSearchMiddleware,
)

View file

@ -213,7 +213,9 @@ def _build_classifier_prompt(*, recent_conversation: str, user_text: str) -> str
)
def _build_recent_conversation(messages: list[BaseMessage], *, max_messages: int = 6) -> str:
def _build_recent_conversation(
messages: list[BaseMessage], *, max_messages: int = 6
) -> str:
rows: list[str] = []
for msg in messages[-max_messages:]:
role = "user" if isinstance(msg, HumanMessage) else "assistant"
@ -246,7 +248,9 @@ class FileIntentMiddleware(AgentMiddleware): # type: ignore[type-arg]
[HumanMessage(content=prompt)],
config={"tags": ["surfsense:internal"]},
)
payload = json.loads(_extract_json_payload(_extract_text_from_message(response)))
payload = json.loads(
_extract_json_payload(_extract_text_from_message(response))
)
plan = FileIntentPlan.model_validate(payload)
return plan
except (json.JSONDecodeError, ValidationError, ValueError) as exc:
@ -317,4 +321,3 @@ class FileIntentMiddleware(AgentMiddleware): # type: ignore[type-arg]
insert_at = max(len(new_messages) - 1, 0)
new_messages.insert(insert_at, contract_msg)
return {"messages": new_messages, "file_operation_contract": contract}

View file

@ -877,7 +877,9 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware):
suggested_path = contract.get("suggested_path")
if isinstance(suggested_path, str) and suggested_path.strip():
normalized_suggested = self._normalize_absolute_path(suggested_path)
suggested_mount = self._extract_mount_from_path(normalized_suggested, mounts)
suggested_mount = self._extract_mount_from_path(
normalized_suggested, mounts
)
matching_mounts = [
mount
@ -1071,14 +1073,18 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware):
] = False,
) -> Command | str:
if self._filesystem_mode != FilesystemMode.DESKTOP_LOCAL_FOLDER:
return "Error: move_file is only available in desktop local-folder mode."
return (
"Error: move_file is only available in desktop local-folder mode."
)
if not source_path.strip() or not destination_path.strip():
return "Error: source_path and destination_path are required."
resolved_backend = self._get_backend(runtime)
source_target = self._resolve_move_target_path(source_path, runtime)
destination_target = self._resolve_move_target_path(destination_path, runtime)
destination_target = self._resolve_move_target_path(
destination_path, runtime
)
try:
validated_source = validate_path(source_target)
validated_destination = validate_path(destination_target)
@ -1106,7 +1112,9 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware):
],
}
)
return f"Moved '{validated_source}' to '{res.path or validated_destination}'"
return (
f"Moved '{validated_source}' to '{res.path or validated_destination}'"
)
async def async_move_file(
source_path: Annotated[
@ -1125,14 +1133,18 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware):
] = False,
) -> Command | str:
if self._filesystem_mode != FilesystemMode.DESKTOP_LOCAL_FOLDER:
return "Error: move_file is only available in desktop local-folder mode."
return (
"Error: move_file is only available in desktop local-folder mode."
)
if not source_path.strip() or not destination_path.strip():
return "Error: source_path and destination_path are required."
resolved_backend = self._get_backend(runtime)
source_target = self._resolve_move_target_path(source_path, runtime)
destination_target = self._resolve_move_target_path(destination_path, runtime)
destination_target = self._resolve_move_target_path(
destination_path, runtime
)
try:
validated_source = validate_path(source_target)
validated_destination = validate_path(destination_target)
@ -1160,7 +1172,9 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware):
],
}
)
return f"Moved '{validated_source}' to '{res.path or validated_destination}'"
return (
f"Moved '{validated_source}' to '{res.path or validated_destination}'"
)
return StructuredTool.from_function(
name="move_file",
@ -1201,7 +1215,9 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware):
] = True,
) -> str:
if self._filesystem_mode != FilesystemMode.DESKTOP_LOCAL_FOLDER:
return "Error: list_tree is only available in desktop local-folder mode."
return (
"Error: list_tree is only available in desktop local-folder mode."
)
if max_depth < 0:
return "Error: max_depth must be >= 0."
if page_size < 1:
@ -1253,7 +1269,9 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware):
] = True,
) -> str:
if self._filesystem_mode != FilesystemMode.DESKTOP_LOCAL_FOLDER:
return "Error: list_tree is only available in desktop local-folder mode."
return (
"Error: list_tree is only available in desktop local-folder mode."
)
if max_depth < 0:
return "Error: max_depth must be >= 0."
if page_size < 1:

View file

@ -27,8 +27,8 @@ from pydantic import BaseModel, Field, ValidationError
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.utils import parse_date_or_datetime, resolve_date_range
from app.agents.new_chat.filesystem_selection import FilesystemMode
from app.agents.new_chat.utils import parse_date_or_datetime, resolve_date_range
from app.db import (
NATIVE_TO_LEGACY_DOCTYPE,
Chunk,

View file

@ -120,7 +120,9 @@ class LocalFolderBackend:
if not target.exists() or not target.is_dir():
return []
infos: list[FileInfo] = []
for child in sorted(target.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
for child in sorted(
target.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())
):
infos.append(
FileInfo(
path=self._to_virtual(child, self._root),
@ -317,7 +319,9 @@ class LocalFolderBackend:
return WriteResult(error="Error: source and destination paths are the same")
with self._acquire_path_locks(source_path, destination_path):
if not source.exists():
return WriteResult(error=f"Error: source path '{source_path}' not found")
return WriteResult(
error=f"Error: source path '{source_path}' not found"
)
if destination.exists():
if not overwrite:
return WriteResult(
@ -339,8 +343,12 @@ class LocalFolderBackend:
else:
source.rename(destination)
except OSError as exc:
return WriteResult(error=f"Error: failed to move '{source_path}': {exc}")
return WriteResult(path=self._to_virtual(destination, self._root), files_update=None)
return WriteResult(
error=f"Error: failed to move '{source_path}': {exc}"
)
return WriteResult(
path=self._to_virtual(destination, self._root), files_update=None
)
async def amove(
self,
@ -368,12 +376,16 @@ class LocalFolderBackend:
if not path.exists() or not path.is_file():
return EditResult(error=f"Error: File '{file_path}' not found")
content = path.read_text(encoding="utf-8", errors="replace")
result = perform_string_replacement(content, old_string, new_string, replace_all)
result = perform_string_replacement(
content, old_string, new_string, replace_all
)
if isinstance(result, str):
return EditResult(error=result)
updated_content, occurrences = result
self._write_text_atomic(path, updated_content)
return EditResult(path=file_path, files_update=None, occurrences=int(occurrences))
return EditResult(
path=file_path, files_update=None, occurrences=int(occurrences)
)
async def aedit(
self,
@ -447,7 +459,9 @@ class LocalFolderBackend:
matches: list[GrepMatch] = []
for file_path in self._iter_candidate_files(path, glob):
try:
lines = file_path.read_text(encoding="utf-8", errors="replace").splitlines()
lines = file_path.read_text(
encoding="utf-8", errors="replace"
).splitlines()
except Exception:
continue
for idx, line in enumerate(lines, start=1):
@ -481,12 +495,18 @@ class LocalFolderBackend:
FileUploadResponse(path=virtual_path, error=_FILE_NOT_FOUND)
)
except IsADirectoryError:
responses.append(FileUploadResponse(path=virtual_path, error=_IS_DIRECTORY))
responses.append(
FileUploadResponse(path=virtual_path, error=_IS_DIRECTORY)
)
except Exception:
responses.append(FileUploadResponse(path=virtual_path, error=_INVALID_PATH))
responses.append(
FileUploadResponse(path=virtual_path, error=_INVALID_PATH)
)
return responses
async def aupload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
async def aupload_files(
self, files: list[tuple[str, bytes]]
) -> list[FileUploadResponse]:
return await asyncio.to_thread(self.upload_files, files)
def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
@ -515,7 +535,9 @@ class LocalFolderBackend:
)
except Exception:
responses.append(
FileDownloadResponse(path=virtual_path, content=None, error=_INVALID_PATH)
FileDownloadResponse(
path=virtual_path, content=None, error=_INVALID_PATH
)
)
return responses

View file

@ -127,7 +127,9 @@ class MultiRootLocalFolderBackend:
mount, local_path = self._split_mount_path(path)
except ValueError:
return []
return self._transform_infos(mount, self._mount_to_backend[mount].ls_info(local_path))
return self._transform_infos(
mount, self._mount_to_backend[mount].ls_info(local_path)
)
async def als_info(self, path: str) -> list[FileInfo]:
return await asyncio.to_thread(self.ls_info, path)
@ -355,7 +357,9 @@ class MultiRootLocalFolderBackend:
all_matches.extend(
[
GrepMatch(
path=self._prefix_mount_path(mount, self._get_str(match, "path")),
path=self._prefix_mount_path(
mount, self._get_str(match, "path")
),
line=self._get_int(match, "line"),
text=self._get_str(match, "text"),
)
@ -394,7 +398,9 @@ class MultiRootLocalFolderBackend:
try:
mount, local_path = self._split_mount_path(virtual_path)
except ValueError:
invalid.append(FileUploadResponse(path=virtual_path, error=_INVALID_PATH))
invalid.append(
FileUploadResponse(path=virtual_path, error=_INVALID_PATH)
)
continue
grouped.setdefault(mount, []).append((local_path, content))
@ -404,7 +410,9 @@ class MultiRootLocalFolderBackend:
responses.extend(
[
FileUploadResponse(
path=self._prefix_mount_path(mount, self._get_str(item, "path")),
path=self._prefix_mount_path(
mount, self._get_str(item, "path")
),
error=self._get_str(item, "error") or None,
)
for item in result
@ -412,7 +420,9 @@ class MultiRootLocalFolderBackend:
)
return responses
async def aupload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
async def aupload_files(
self, files: list[tuple[str, bytes]]
) -> list[FileUploadResponse]:
return await asyncio.to_thread(self.upload_files, files)
def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
@ -423,7 +433,9 @@ class MultiRootLocalFolderBackend:
mount, local_path = self._split_mount_path(virtual_path)
except ValueError:
invalid.append(
FileDownloadResponse(path=virtual_path, content=None, error=_INVALID_PATH)
FileDownloadResponse(
path=virtual_path, content=None, error=_INVALID_PATH
)
)
continue
grouped.setdefault(mount, []).append(local_path)
@ -434,7 +446,9 @@ class MultiRootLocalFolderBackend:
responses.extend(
[
FileDownloadResponse(
path=self._prefix_mount_path(mount, self._get_str(item, "path")),
path=self._prefix_mount_path(
mount, self._get_str(item, "path")
),
content=self._get_value(item, "content"),
error=self._get_str(item, "error") or None,
)

View file

@ -57,7 +57,11 @@ def create_get_connected_accounts_tool(
async def _run(service: str) -> list[dict[str, Any]]:
svc_cfg = MCP_SERVICES.get(service)
if not svc_cfg:
return [{"error": f"Unknown service '{service}'. Valid: {', '.join(sorted(MCP_SERVICES.keys()))}"}]
return [
{
"error": f"Unknown service '{service}'. Valid: {', '.join(sorted(MCP_SERVICES.keys()))}"
}
]
try:
connector_type = SearchSourceConnectorType(svc_cfg.connector_type)
@ -74,7 +78,11 @@ def create_get_connected_accounts_tool(
connectors = result.scalars().all()
if not connectors:
return [{"error": f"No {svc_cfg.name} accounts connected. Ask the user to connect one in settings."}]
return [
{
"error": f"No {svc_cfg.name} accounts connected. Ask the user to connect one in settings."
}
]
is_multi = len(connectors) > 1

View file

@ -19,7 +19,8 @@ async def get_discord_connector(
select(SearchSourceConnector).filter(
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type == SearchSourceConnectorType.DISCORD_CONNECTOR,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.DISCORD_CONNECTOR,
)
)
return result.scalars().first()

View file

@ -23,16 +23,24 @@ def create_list_discord_channels_tool(
Dictionary with status and a list of channels (id, name).
"""
if db_session is None or search_space_id is None or user_id is None:
return {"status": "error", "message": "Discord tool not properly configured."}
return {
"status": "error",
"message": "Discord tool not properly configured.",
}
try:
connector = await get_discord_connector(db_session, search_space_id, user_id)
connector = await get_discord_connector(
db_session, search_space_id, user_id
)
if not connector:
return {"status": "error", "message": "No Discord connector found."}
guild_id = get_guild_id(connector)
if not guild_id:
return {"status": "error", "message": "No guild ID in Discord connector config."}
return {
"status": "error",
"message": "No guild ID in Discord connector config.",
}
token = get_bot_token(connector)
@ -44,9 +52,16 @@ def create_list_discord_channels_tool(
)
if resp.status_code == 401:
return {"status": "auth_error", "message": "Discord bot token is invalid.", "connector_type": "discord"}
return {
"status": "auth_error",
"message": "Discord bot token is invalid.",
"connector_type": "discord",
}
if resp.status_code != 200:
return {"status": "error", "message": f"Discord API error: {resp.status_code}"}
return {
"status": "error",
"message": f"Discord API error: {resp.status_code}",
}
# Type 0 = text channel
channels = [
@ -54,7 +69,12 @@ def create_list_discord_channels_tool(
for ch in resp.json()
if ch.get("type") == 0
]
return {"status": "success", "guild_id": guild_id, "channels": channels, "total": len(channels)}
return {
"status": "success",
"guild_id": guild_id,
"channels": channels,
"total": len(channels),
}
except Exception as e:
from langgraph.errors import GraphInterrupt

View file

@ -31,12 +31,17 @@ def create_read_discord_messages_tool(
id, author, content, timestamp.
"""
if db_session is None or search_space_id is None or user_id is None:
return {"status": "error", "message": "Discord tool not properly configured."}
return {
"status": "error",
"message": "Discord tool not properly configured.",
}
limit = min(limit, 50)
try:
connector = await get_discord_connector(db_session, search_space_id, user_id)
connector = await get_discord_connector(
db_session, search_space_id, user_id
)
if not connector:
return {"status": "error", "message": "No Discord connector found."}
@ -51,11 +56,21 @@ def create_read_discord_messages_tool(
)
if resp.status_code == 401:
return {"status": "auth_error", "message": "Discord bot token is invalid.", "connector_type": "discord"}
return {
"status": "auth_error",
"message": "Discord bot token is invalid.",
"connector_type": "discord",
}
if resp.status_code == 403:
return {"status": "error", "message": "Bot lacks permission to read this channel."}
return {
"status": "error",
"message": "Bot lacks permission to read this channel.",
}
if resp.status_code != 200:
return {"status": "error", "message": f"Discord API error: {resp.status_code}"}
return {
"status": "error",
"message": f"Discord API error: {resp.status_code}",
}
messages = [
{
@ -67,7 +82,12 @@ def create_read_discord_messages_tool(
for m in resp.json()
]
return {"status": "success", "channel_id": channel_id, "messages": messages, "total": len(messages)}
return {
"status": "success",
"channel_id": channel_id,
"messages": messages,
"total": len(messages),
}
except Exception as e:
from langgraph.errors import GraphInterrupt

View file

@ -35,13 +35,21 @@ def create_send_discord_message_tool(
- If status is "rejected", the user explicitly declined. Do NOT retry.
"""
if db_session is None or search_space_id is None or user_id is None:
return {"status": "error", "message": "Discord tool not properly configured."}
return {
"status": "error",
"message": "Discord tool not properly configured.",
}
if len(content) > 2000:
return {"status": "error", "message": "Message exceeds Discord's 2000-character limit."}
return {
"status": "error",
"message": "Message exceeds Discord's 2000-character limit.",
}
try:
connector = await get_discord_connector(db_session, search_space_id, user_id)
connector = await get_discord_connector(
db_session, search_space_id, user_id
)
if not connector:
return {"status": "error", "message": "No Discord connector found."}
@ -53,7 +61,10 @@ def create_send_discord_message_tool(
)
if result.rejected:
return {"status": "rejected", "message": "User declined. Message was not sent."}
return {
"status": "rejected",
"message": "User declined. Message was not sent.",
}
final_content = result.params.get("content", content)
final_channel = result.params.get("channel_id", channel_id)
@ -72,11 +83,21 @@ def create_send_discord_message_tool(
)
if resp.status_code == 401:
return {"status": "auth_error", "message": "Discord bot token is invalid.", "connector_type": "discord"}
return {
"status": "auth_error",
"message": "Discord bot token is invalid.",
"connector_type": "discord",
}
if resp.status_code == 403:
return {"status": "error", "message": "Bot lacks permission to send messages in this channel."}
return {
"status": "error",
"message": "Bot lacks permission to send messages in this channel.",
}
if resp.status_code not in (200, 201):
return {"status": "error", "message": f"Discord API error: {resp.status_code}"}
return {
"status": "error",
"message": f"Discord API error: {resp.status_code}",
}
msg_data = resp.json()
return {

View file

@ -65,12 +65,22 @@ def create_read_gmail_email_tool(
detail, error = await gmail.get_message_details(message_id)
if error:
if "re-authenticate" in error.lower() or "authentication failed" in error.lower():
return {"status": "auth_error", "message": error, "connector_type": "gmail"}
if (
"re-authenticate" in error.lower()
or "authentication failed" in error.lower()
):
return {
"status": "auth_error",
"message": error,
"connector_type": "gmail",
}
return {"status": "error", "message": error}
if not detail:
return {"status": "not_found", "message": f"Email with ID '{message_id}' not found."}
return {
"status": "not_found",
"message": f"Email with ID '{message_id}' not found.",
}
content = gmail.format_message_to_markdown(detail)
@ -82,6 +92,9 @@ def create_read_gmail_email_tool(
if isinstance(e, GraphInterrupt):
raise
logger.error("Error reading Gmail email: %s", e, exc_info=True)
return {"status": "error", "message": "Failed to read email. Please try again."}
return {
"status": "error",
"message": "Failed to read email. Please try again.",
}
return read_gmail_email

View file

@ -125,12 +125,24 @@ def create_search_gmail_tool(
max_results=max_results, query=query
)
if error:
if "re-authenticate" in error.lower() or "authentication failed" in error.lower():
return {"status": "auth_error", "message": error, "connector_type": "gmail"}
if (
"re-authenticate" in error.lower()
or "authentication failed" in error.lower()
):
return {
"status": "auth_error",
"message": error,
"connector_type": "gmail",
}
return {"status": "error", "message": error}
if not messages_list:
return {"status": "success", "emails": [], "total": 0, "message": "No emails found."}
return {
"status": "success",
"emails": [],
"total": 0,
"message": "No emails found.",
}
emails = []
for msg in messages_list:
@ -141,16 +153,18 @@ def create_search_gmail_tool(
h["name"].lower(): h["value"]
for h in detail.get("payload", {}).get("headers", [])
}
emails.append({
"message_id": detail.get("id"),
"thread_id": detail.get("threadId"),
"subject": headers.get("subject", "No Subject"),
"from": headers.get("from", "Unknown"),
"to": headers.get("to", ""),
"date": headers.get("date", ""),
"snippet": detail.get("snippet", ""),
"labels": detail.get("labelIds", []),
})
emails.append(
{
"message_id": detail.get("id"),
"thread_id": detail.get("threadId"),
"subject": headers.get("subject", "No Subject"),
"from": headers.get("from", "Unknown"),
"to": headers.get("to", ""),
"date": headers.get("date", ""),
"snippet": detail.get("snippet", ""),
"labels": detail.get("labelIds", []),
}
)
return {"status": "success", "emails": emails, "total": len(emails)}
@ -160,6 +174,9 @@ def create_search_gmail_tool(
if isinstance(e, GraphInterrupt):
raise
logger.error("Error searching Gmail: %s", e, exc_info=True)
return {"status": "error", "message": "Failed to search Gmail. Please try again."}
return {
"status": "error",
"message": "Failed to search Gmail. Please try again.",
}
return search_gmail

View file

@ -39,7 +39,10 @@ def create_search_calendar_events_tool(
event_id, summary, start, end, location, attendees.
"""
if db_session is None or search_space_id is None or user_id is None:
return {"status": "error", "message": "Calendar tool not properly configured."}
return {
"status": "error",
"message": "Calendar tool not properly configured.",
}
max_results = min(max_results, 50)
@ -76,10 +79,22 @@ def create_search_calendar_events_tool(
)
if error:
if "re-authenticate" in error.lower() or "authentication failed" in error.lower():
return {"status": "auth_error", "message": error, "connector_type": "google_calendar"}
if (
"re-authenticate" in error.lower()
or "authentication failed" in error.lower()
):
return {
"status": "auth_error",
"message": error,
"connector_type": "google_calendar",
}
if "no events found" in error.lower():
return {"status": "success", "events": [], "total": 0, "message": error}
return {
"status": "success",
"events": [],
"total": 0,
"message": error,
}
return {"status": "error", "message": error}
events = []
@ -87,19 +102,19 @@ def create_search_calendar_events_tool(
start = ev.get("start", {})
end = ev.get("end", {})
attendees_raw = ev.get("attendees", [])
events.append({
"event_id": ev.get("id"),
"summary": ev.get("summary", "No Title"),
"start": start.get("dateTime") or start.get("date", ""),
"end": end.get("dateTime") or end.get("date", ""),
"location": ev.get("location", ""),
"description": ev.get("description", ""),
"html_link": ev.get("htmlLink", ""),
"attendees": [
a.get("email", "") for a in attendees_raw[:10]
],
"status": ev.get("status", ""),
})
events.append(
{
"event_id": ev.get("id"),
"summary": ev.get("summary", "No Title"),
"start": start.get("dateTime") or start.get("date", ""),
"end": end.get("dateTime") or end.get("date", ""),
"location": ev.get("location", ""),
"description": ev.get("description", ""),
"html_link": ev.get("htmlLink", ""),
"attendees": [a.get("email", "") for a in attendees_raw[:10]],
"status": ev.get("status", ""),
}
)
return {"status": "success", "events": events, "total": len(events)}
@ -109,6 +124,9 @@ def create_search_calendar_events_tool(
if isinstance(e, GraphInterrupt):
raise
logger.error("Error searching calendar events: %s", e, exc_info=True)
return {"status": "error", "message": "Failed to search calendar events. Please try again."}
return {
"status": "error",
"message": "Failed to search calendar events. Please try again.",
}
return search_calendar_events

View file

@ -130,7 +130,9 @@ def request_approval(
try:
decision_type, edited_params = _parse_decision(approval)
except ValueError:
logger.warning("No approval decision received for %s — rejecting for safety", tool_name)
logger.warning(
"No approval decision received for %s — rejecting for safety", tool_name
)
return HITLResult(rejected=True, decision_type="error", params=params)
logger.info("User decision for %s: %s", tool_name, decision_type)

View file

@ -17,7 +17,8 @@ async def get_luma_connector(
select(SearchSourceConnector).filter(
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type == SearchSourceConnectorType.LUMA_CONNECTOR,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.LUMA_CONNECTOR,
)
)
return result.scalars().first()

View file

@ -62,7 +62,10 @@ def create_create_luma_event_tool(
)
if result.rejected:
return {"status": "rejected", "message": "User declined. Event was not created."}
return {
"status": "rejected",
"message": "User declined. Event was not created.",
}
final_name = result.params.get("name", name)
final_start = result.params.get("start_at", start_at)
@ -90,11 +93,21 @@ def create_create_luma_event_tool(
)
if resp.status_code == 401:
return {"status": "auth_error", "message": "Luma API key is invalid.", "connector_type": "luma"}
return {
"status": "auth_error",
"message": "Luma API key is invalid.",
"connector_type": "luma",
}
if resp.status_code == 403:
return {"status": "error", "message": "Luma Plus subscription required to create events via API."}
return {
"status": "error",
"message": "Luma Plus subscription required to create events via API.",
}
if resp.status_code not in (200, 201):
return {"status": "error", "message": f"Luma API error: {resp.status_code}{resp.text[:200]}"}
return {
"status": "error",
"message": f"Luma API error: {resp.status_code}{resp.text[:200]}",
}
data = resp.json()
event_id = data.get("api_id") or data.get("event", {}).get("api_id")

View file

@ -46,7 +46,9 @@ def create_list_luma_events_tool(
async with httpx.AsyncClient(timeout=20.0) as client:
while len(all_entries) < max_results:
params: dict[str, Any] = {"limit": min(100, max_results - len(all_entries))}
params: dict[str, Any] = {
"limit": min(100, max_results - len(all_entries))
}
if cursor:
params["cursor"] = cursor
@ -57,9 +59,16 @@ def create_list_luma_events_tool(
)
if resp.status_code == 401:
return {"status": "auth_error", "message": "Luma API key is invalid.", "connector_type": "luma"}
return {
"status": "auth_error",
"message": "Luma API key is invalid.",
"connector_type": "luma",
}
if resp.status_code != 200:
return {"status": "error", "message": f"Luma API error: {resp.status_code}"}
return {
"status": "error",
"message": f"Luma API error: {resp.status_code}",
}
data = resp.json()
entries = data.get("entries", [])
@ -76,16 +85,18 @@ def create_list_luma_events_tool(
for entry in all_entries[:max_results]:
ev = entry.get("event", {})
geo = ev.get("geo_info", {})
events.append({
"event_id": entry.get("api_id"),
"name": ev.get("name", "Untitled"),
"start_at": ev.get("start_at", ""),
"end_at": ev.get("end_at", ""),
"timezone": ev.get("timezone", ""),
"location": geo.get("name", ""),
"url": ev.get("url", ""),
"visibility": ev.get("visibility", ""),
})
events.append(
{
"event_id": entry.get("api_id"),
"name": ev.get("name", "Untitled"),
"start_at": ev.get("start_at", ""),
"end_at": ev.get("end_at", ""),
"timezone": ev.get("timezone", ""),
"location": geo.get("name", ""),
"url": ev.get("url", ""),
"visibility": ev.get("visibility", ""),
}
)
return {"status": "success", "events": events, "total": len(events)}

View file

@ -44,11 +44,21 @@ def create_read_luma_event_tool(
)
if resp.status_code == 401:
return {"status": "auth_error", "message": "Luma API key is invalid.", "connector_type": "luma"}
return {
"status": "auth_error",
"message": "Luma API key is invalid.",
"connector_type": "luma",
}
if resp.status_code == 404:
return {"status": "not_found", "message": f"Event '{event_id}' not found."}
return {
"status": "not_found",
"message": f"Event '{event_id}' not found.",
}
if resp.status_code != 200:
return {"status": "error", "message": f"Luma API error: {resp.status_code}"}
return {
"status": "error",
"message": f"Luma API error: {resp.status_code}",
}
data = resp.json()
ev = data.get("event", data)

View file

@ -220,10 +220,8 @@ class MCPClient:
logger.info("MCP tool '%s' succeeded: %s", tool_name, result_str[:200])
return result_str
except asyncio.TimeoutError:
logger.error(
"MCP tool '%s' timed out after %.0fs", tool_name, timeout
)
except TimeoutError:
logger.error("MCP tool '%s' timed out after %.0fs", tool_name, timeout)
return f"Error: MCP tool '{tool_name}' timed out after {timeout:.0f}s"
except RuntimeError as e:
if "Invalid structured content" in str(e):

View file

@ -35,7 +35,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.tools.hitl import request_approval
from app.agents.new_chat.tools.mcp_client import MCPClient
from app.db import SearchSourceConnector, SearchSourceConnectorType
from app.db import SearchSourceConnector
from app.services.mcp_oauth.registry import MCP_SERVICES, get_service_by_connector_type
logger = logging.getLogger(__name__)
@ -105,13 +105,15 @@ def _create_dynamic_input_model_from_schema(
description=(
"Arguments to pass to this tool as a JSON object. "
"Infer sensible key names from the tool name and description "
"(e.g. {\"search\": \"my query\"} for a search tool)."
'(e.g. {"search": "my query"} for a search tool).'
),
),
)
model_name = f"{tool_name.replace(' ', '').replace('-', '_')}Input"
model = create_model(model_name, __config__=ConfigDict(extra="allow"), **field_definitions)
model = create_model(
model_name, __config__=ConfigDict(extra="allow"), **field_definitions
)
return model
@ -187,16 +189,23 @@ async def _create_mcp_tool_from_definition_stdio(
except Exception as e:
last_error = e
if attempt < _TOOL_CALL_MAX_RETRIES - 1:
delay = _TOOL_CALL_RETRY_DELAY * (2 ** attempt)
delay = _TOOL_CALL_RETRY_DELAY * (2**attempt)
logger.warning(
"MCP tool '%s' failed (attempt %d/%d): %s. Retrying in %.1fs...",
tool_name, attempt + 1, _TOOL_CALL_MAX_RETRIES, e, delay,
tool_name,
attempt + 1,
_TOOL_CALL_MAX_RETRIES,
e,
delay,
)
await asyncio.sleep(delay)
else:
logger.error(
"MCP tool '%s' failed after %d attempts: %s",
tool_name, _TOOL_CALL_MAX_RETRIES, e, exc_info=True,
tool_name,
_TOOL_CALL_MAX_RETRIES,
e,
exc_info=True,
)
return f"Error: MCP tool '{tool_name}' failed after {_TOOL_CALL_MAX_RETRIES} attempts: {last_error!s}"
@ -318,17 +327,22 @@ async def _create_mcp_tool_from_definition_http(
try:
result_str = await _do_mcp_call(headers, call_kwargs)
logger.debug("MCP HTTP tool '%s' succeeded (len=%d)", exposed_name, len(result_str))
logger.debug(
"MCP HTTP tool '%s' succeeded (len=%d)", exposed_name, len(result_str)
)
return result_str
except Exception as first_err:
if not _is_auth_error(first_err) or connector_id is None:
logger.exception("MCP HTTP tool '%s' execution failed: %s", exposed_name, first_err)
logger.exception(
"MCP HTTP tool '%s' execution failed: %s", exposed_name, first_err
)
return f"Error: MCP HTTP tool '{exposed_name}' execution failed: {first_err!s}"
logger.warning(
"MCP HTTP tool '%s' got 401 — attempting token refresh for connector %s",
exposed_name, connector_id,
exposed_name,
connector_id,
)
fresh_headers = await _force_refresh_and_get_headers(connector_id)
if fresh_headers is None:
@ -348,7 +362,8 @@ async def _create_mcp_tool_from_definition_http(
except Exception as retry_err:
logger.exception(
"MCP HTTP tool '%s' still failing after token refresh: %s",
exposed_name, retry_err,
exposed_name,
retry_err,
)
if _is_auth_error(retry_err):
await _mark_connector_auth_expired(connector_id)
@ -393,7 +408,8 @@ async def _load_stdio_mcp_tools(
if not command or not isinstance(command, str):
logger.warning(
"MCP connector %d (name: '%s') missing or invalid command field, skipping",
connector_id, connector_name,
connector_id,
connector_name,
)
return tools
@ -401,7 +417,8 @@ async def _load_stdio_mcp_tools(
if not isinstance(args, list):
logger.warning(
"MCP connector %d (name: '%s') has invalid args field (must be list), skipping",
connector_id, connector_name,
connector_id,
connector_name,
)
return tools
@ -409,7 +426,8 @@ async def _load_stdio_mcp_tools(
if not isinstance(env, dict):
logger.warning(
"MCP connector %d (name: '%s') has invalid env field (must be dict), skipping",
connector_id, connector_name,
connector_id,
connector_name,
)
return tools
@ -420,7 +438,9 @@ async def _load_stdio_mcp_tools(
logger.info(
"Discovered %d tools from stdio MCP server '%s' (connector %d)",
len(tool_definitions), command, connector_id,
len(tool_definitions),
command,
connector_id,
)
for tool_def in tool_definitions:
@ -436,7 +456,9 @@ async def _load_stdio_mcp_tools(
except Exception as e:
logger.exception(
"Failed to create tool '%s' from connector %d: %s",
tool_def.get("name"), connector_id, e,
tool_def.get("name"),
connector_id,
e,
)
return tools
@ -468,7 +490,8 @@ async def _load_http_mcp_tools(
if not url or not isinstance(url, str):
logger.warning(
"MCP connector %d (name: '%s') missing or invalid url field, skipping",
connector_id, connector_name,
connector_id,
connector_name,
)
return tools
@ -476,7 +499,8 @@ async def _load_http_mcp_tools(
if not isinstance(headers, dict):
logger.warning(
"MCP connector %d (name: '%s') has invalid headers field (must be dict), skipping",
connector_id, connector_name,
connector_id,
connector_name,
)
return tools
@ -507,7 +531,9 @@ async def _load_http_mcp_tools(
if not _is_auth_error(first_err) or connector_id is None:
logger.exception(
"Failed to connect to HTTP MCP server at '%s' (connector %d): %s",
url, connector_id, first_err,
url,
connector_id,
first_err,
)
return tools
@ -534,7 +560,8 @@ async def _load_http_mcp_tools(
except Exception as retry_err:
logger.exception(
"HTTP MCP discovery for connector %d still failing after refresh: %s",
connector_id, retry_err,
connector_id,
retry_err,
)
if _is_auth_error(retry_err):
await _mark_connector_auth_expired(connector_id)
@ -543,17 +570,20 @@ async def _load_http_mcp_tools(
total_discovered = len(tool_definitions)
if allowed_set:
tool_definitions = [
td for td in tool_definitions if td["name"] in allowed_set
]
tool_definitions = [td for td in tool_definitions if td["name"] in allowed_set]
logger.info(
"HTTP MCP server '%s' (connector %d): %d/%d tools after allowlist filter",
url, connector_id, len(tool_definitions), total_discovered,
url,
connector_id,
len(tool_definitions),
total_discovered,
)
else:
logger.info(
"Discovered %d tools from HTTP MCP server '%s' (connector %d) — no allowlist, loading all",
total_discovered, url, connector_id,
total_discovered,
url,
connector_id,
)
for tool_def in tool_definitions:
@ -573,7 +603,9 @@ async def _load_http_mcp_tools(
except Exception as e:
logger.exception(
"Failed to create HTTP tool '%s' from connector %d: %s",
tool_def.get("name"), connector_id, e,
tool_def.get("name"),
connector_id,
e,
)
return tools
@ -628,7 +660,7 @@ def _inject_oauth_headers(
async def _refresh_connector_token(
session: AsyncSession,
connector: "SearchSourceConnector",
connector: SearchSourceConnector,
) -> str | None:
"""Refresh the OAuth token for an MCP connector and persist the result.
@ -692,12 +724,8 @@ async def _refresh_connector_token(
updated_oauth = dict(mcp_oauth)
updated_oauth["access_token"] = enc.encrypt_token(new_access)
if token_json.get("refresh_token"):
updated_oauth["refresh_token"] = enc.encrypt_token(
token_json["refresh_token"]
)
updated_oauth["expires_at"] = (
new_expires_at.isoformat() if new_expires_at else None
)
updated_oauth["refresh_token"] = enc.encrypt_token(token_json["refresh_token"])
updated_oauth["expires_at"] = new_expires_at.isoformat() if new_expires_at else None
updated_cfg = {**cfg, "mcp_oauth": updated_oauth}
updated_cfg.pop("auth_expired", None)
@ -713,7 +741,7 @@ async def _refresh_connector_token(
async def _maybe_refresh_mcp_oauth_token(
session: AsyncSession,
connector: "SearchSourceConnector",
connector: SearchSourceConnector,
cfg: dict[str, Any],
server_config: dict[str, Any],
) -> dict[str, Any]:
@ -731,10 +759,11 @@ async def _maybe_refresh_mcp_oauth_token(
try:
expires_at = datetime.fromisoformat(expires_at_str)
if expires_at.tzinfo is None:
from datetime import timezone
expires_at = expires_at.replace(tzinfo=timezone.utc)
expires_at = expires_at.replace(tzinfo=UTC)
if datetime.now(UTC) < expires_at - timedelta(seconds=_TOKEN_REFRESH_BUFFER_SECONDS):
if datetime.now(UTC) < expires_at - timedelta(
seconds=_TOKEN_REFRESH_BUFFER_SECONDS
):
return server_config
except (ValueError, TypeError):
return server_config
@ -744,7 +773,9 @@ async def _maybe_refresh_mcp_oauth_token(
if not new_access:
return server_config
logger.info("Proactively refreshed MCP OAuth token for connector %s", connector.id)
logger.info(
"Proactively refreshed MCP OAuth token for connector %s", connector.id
)
refreshed_config = dict(server_config)
refreshed_config["headers"] = {
@ -920,7 +951,7 @@ async def load_mcp_tools(
result = await session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.search_space_id == search_space_id,
cast(SearchSourceConnector.config, JSONB).has_key("server_config"), # noqa: W601
cast(SearchSourceConnector.config, JSONB).has_key("server_config"),
),
)
@ -956,13 +987,17 @@ async def load_mcp_tools(
if not server_config or not isinstance(server_config, dict):
logger.warning(
"MCP connector %d (name: '%s') has invalid or missing server_config, skipping",
connector.id, connector.name,
connector.id,
connector.name,
)
continue
if cfg.get("mcp_oauth"):
server_config = await _maybe_refresh_mcp_oauth_token(
session, connector, cfg, server_config,
session,
connector,
cfg,
server_config,
)
cfg = connector.config or {}
server_config = _inject_oauth_headers(cfg, server_config)
@ -995,22 +1030,25 @@ async def load_mcp_tools(
if service_key:
tool_name_prefix = f"{service_key}_{connector.id}"
discovery_tasks.append({
"connector_id": connector.id,
"connector_name": connector.name,
"server_config": server_config,
"trusted_tools": trusted_tools,
"allowed_tools": allowed_tools,
"readonly_tools": readonly_tools,
"tool_name_prefix": tool_name_prefix,
"transport": server_config.get("transport", "stdio"),
"is_generic_mcp": svc_cfg is None,
})
discovery_tasks.append(
{
"connector_id": connector.id,
"connector_name": connector.name,
"server_config": server_config,
"trusted_tools": trusted_tools,
"allowed_tools": allowed_tools,
"readonly_tools": readonly_tools,
"tool_name_prefix": tool_name_prefix,
"transport": server_config.get("transport", "stdio"),
"is_generic_mcp": svc_cfg is None,
}
)
except Exception as e:
logger.exception(
"Failed to prepare MCP connector %d: %s",
connector.id, e,
connector.id,
e,
)
async def _discover_one(task: dict[str, Any]) -> list[StructuredTool]:
@ -1039,23 +1077,23 @@ async def load_mcp_tools(
),
timeout=_MCP_DISCOVERY_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
except TimeoutError:
logger.error(
"MCP connector %d timed out after %ds during discovery",
task["connector_id"], _MCP_DISCOVERY_TIMEOUT_SECONDS,
task["connector_id"],
_MCP_DISCOVERY_TIMEOUT_SECONDS,
)
return []
except Exception as e:
logger.exception(
"Failed to load tools from MCP connector %d: %s",
task["connector_id"], e,
task["connector_id"],
e,
)
return []
results = await asyncio.gather(*[_discover_one(t) for t in discovery_tasks])
tools: list[StructuredTool] = [
tool for sublist in results for tool in sublist
]
tools: list[StructuredTool] = [tool for sublist in results for tool in sublist]
_mcp_tools_cache[search_space_id] = (now, tools)
@ -1063,7 +1101,9 @@ async def load_mcp_tools(
oldest_key = min(_mcp_tools_cache, key=lambda k: _mcp_tools_cache[k][0])
del _mcp_tools_cache[oldest_key]
logger.info("Loaded %d MCP tools for search space %d", len(tools), search_space_id)
logger.info(
"Loaded %d MCP tools for search space %d", len(tools), search_space_id
)
return tools
except Exception as e:

View file

@ -50,6 +50,7 @@ from .confluence import (
create_delete_confluence_page_tool,
create_update_confluence_page_tool,
)
from .connected_accounts import create_get_connected_accounts_tool
from .discord import (
create_list_discord_channels_tool,
create_read_discord_messages_tool,
@ -78,7 +79,6 @@ from .google_drive import (
create_create_google_drive_file_tool,
create_delete_google_drive_file_tool,
)
from .connected_accounts import create_get_connected_accounts_tool
from .luma import (
create_create_luma_event_tool,
create_list_luma_events_tool,
@ -675,10 +675,7 @@ def get_connector_gated_tools(
available_connectors: list[str] | None,
) -> list[str]:
"""Return tool names to disable"""
if available_connectors is None:
available = set()
else:
available = set(available_connectors)
available = set() if available_connectors is None else set(available_connectors)
disabled: list[str] = []
for tool_def in BUILTIN_TOOLS:
@ -829,14 +826,16 @@ async def build_tools_async(
tools.extend(mcp_tools)
logging.info(
"Registered %d MCP tools: %s",
len(mcp_tools), [t.name for t in mcp_tools],
len(mcp_tools),
[t.name for t in mcp_tools],
)
except Exception as e:
logging.exception("Failed to load MCP tools: %s", e)
logging.info(
"Total tools for agent: %d%s",
len(tools), [t.name for t in tools],
len(tools),
[t.name for t in tools],
)
return tools

View file

@ -17,7 +17,8 @@ async def get_teams_connector(
select(SearchSourceConnector).filter(
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type == SearchSourceConnectorType.TEAMS_CONNECTOR,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.TEAMS_CONNECTOR,
)
)
return result.scalars().first()

View file

@ -35,12 +35,21 @@ def create_list_teams_channels_tool(
headers = {"Authorization": f"Bearer {token}"}
async with httpx.AsyncClient(timeout=20.0) as client:
teams_resp = await client.get(f"{GRAPH_API}/me/joinedTeams", headers=headers)
teams_resp = await client.get(
f"{GRAPH_API}/me/joinedTeams", headers=headers
)
if teams_resp.status_code == 401:
return {"status": "auth_error", "message": "Teams token expired. Please re-authenticate.", "connector_type": "teams"}
return {
"status": "auth_error",
"message": "Teams token expired. Please re-authenticate.",
"connector_type": "teams",
}
if teams_resp.status_code != 200:
return {"status": "error", "message": f"Graph API error: {teams_resp.status_code}"}
return {
"status": "error",
"message": f"Graph API error: {teams_resp.status_code}",
}
teams_data = teams_resp.json().get("value", [])
result_teams = []
@ -58,13 +67,19 @@ def create_list_teams_channels_tool(
{"id": ch["id"], "name": ch.get("displayName", "")}
for ch in ch_resp.json().get("value", [])
]
result_teams.append({
"team_id": team_id,
"team_name": team.get("displayName", ""),
"channels": channels,
})
result_teams.append(
{
"team_id": team_id,
"team_name": team.get("displayName", ""),
"channels": channels,
}
)
return {"status": "success", "teams": result_teams, "total_teams": len(result_teams)}
return {
"status": "success",
"teams": result_teams,
"total_teams": len(result_teams),
}
except Exception as e:
from langgraph.errors import GraphInterrupt

View file

@ -52,11 +52,21 @@ def create_read_teams_messages_tool(
)
if resp.status_code == 401:
return {"status": "auth_error", "message": "Teams token expired. Please re-authenticate.", "connector_type": "teams"}
return {
"status": "auth_error",
"message": "Teams token expired. Please re-authenticate.",
"connector_type": "teams",
}
if resp.status_code == 403:
return {"status": "error", "message": "Insufficient permissions to read this channel."}
return {
"status": "error",
"message": "Insufficient permissions to read this channel.",
}
if resp.status_code != 200:
return {"status": "error", "message": f"Graph API error: {resp.status_code}"}
return {
"status": "error",
"message": f"Graph API error: {resp.status_code}",
}
raw_msgs = resp.json().get("value", [])
messages = []
@ -64,13 +74,15 @@ def create_read_teams_messages_tool(
sender = m.get("from", {})
user_info = sender.get("user", {}) if sender else {}
body = m.get("body", {})
messages.append({
"id": m.get("id"),
"sender": user_info.get("displayName", "Unknown"),
"content": body.get("content", ""),
"content_type": body.get("contentType", "text"),
"timestamp": m.get("createdDateTime", ""),
})
messages.append(
{
"id": m.get("id"),
"sender": user_info.get("displayName", "Unknown"),
"content": body.get("content", ""),
"content_type": body.get("contentType", "text"),
"timestamp": m.get("createdDateTime", ""),
}
)
return {
"status": "success",

View file

@ -50,12 +50,19 @@ def create_send_teams_message_tool(
result = request_approval(
action_type="teams_send_message",
tool_name="send_teams_message",
params={"team_id": team_id, "channel_id": channel_id, "content": content},
params={
"team_id": team_id,
"channel_id": channel_id,
"content": content,
},
context={"connector_id": connector.id},
)
if result.rejected:
return {"status": "rejected", "message": "User declined. Message was not sent."}
return {
"status": "rejected",
"message": "User declined. Message was not sent.",
}
final_content = result.params.get("content", content)
final_team = result.params.get("team_id", team_id)
@ -74,20 +81,27 @@ def create_send_teams_message_tool(
)
if resp.status_code == 401:
return {"status": "auth_error", "message": "Teams token expired. Please re-authenticate.", "connector_type": "teams"}
return {
"status": "auth_error",
"message": "Teams token expired. Please re-authenticate.",
"connector_type": "teams",
}
if resp.status_code == 403:
return {
"status": "insufficient_permissions",
"message": "Missing ChannelMessage.Send permission. Please re-authenticate with updated scopes.",
}
if resp.status_code not in (200, 201):
return {"status": "error", "message": f"Graph API error: {resp.status_code}{resp.text[:200]}"}
return {
"status": "error",
"message": f"Graph API error: {resp.status_code}{resp.text[:200]}",
}
msg_data = resp.json()
return {
"status": "success",
"message_id": msg_data.get("id"),
"message": f"Message sent to Teams channel.",
"message": "Message sent to Teams channel.",
}
except Exception as e:

View file

@ -6,7 +6,6 @@ from typing import Any
class ToolResponse:
@staticmethod
def success(message: str, **data: Any) -> dict[str, Any]:
return {"status": "success", "message": message, **data}
@ -31,9 +30,7 @@ class ToolResponse:
return {"status": "rejected", "message": message}
@staticmethod
def not_found(
resource: str, identifier: str, **data: Any
) -> dict[str, Any]:
def not_found(resource: str, identifier: str, **data: Any) -> dict[str, Any]:
return {
"status": "not_found",
"error": f"{resource} '{identifier}' was not found.",

View file

@ -13,7 +13,6 @@ from typing import Any
class ConnectorError(Exception):
def __init__(
self,
message: str,

View file

@ -98,7 +98,9 @@ router.include_router(logs_router)
router.include_router(circleback_webhook_router) # Circleback meeting webhooks
router.include_router(surfsense_docs_router) # Surfsense documentation for citations
router.include_router(notifications_router) # Notifications with Zero sync
router.include_router(mcp_oauth_router) # MCP OAuth 2.1 for Linear, Jira, ClickUp, Slack, Airtable
router.include_router(
mcp_oauth_router
) # MCP OAuth 2.1 for Linear, Jira, ClickUp, Slack, Airtable
router.include_router(composio_router) # Composio OAuth and toolkit management
router.include_router(public_chat_router) # Public chat sharing and cloning
router.include_router(incentive_tasks_router) # Incentive tasks for earning free pages

View file

@ -29,7 +29,11 @@ from app.db import (
)
from app.users import current_active_user
from app.utils.connector_naming import generate_unique_connector_name
from app.utils.oauth_security import OAuthStateManager, TokenEncryption, generate_pkce_pair
from app.utils.oauth_security import (
OAuthStateManager,
TokenEncryption,
generate_pkce_pair,
)
logger = logging.getLogger(__name__)
@ -37,7 +41,9 @@ router = APIRouter()
async def _fetch_account_metadata(
service_key: str, access_token: str, token_json: dict[str, Any],
service_key: str,
access_token: str,
token_json: dict[str, Any],
) -> dict[str, Any]:
"""Fetch display-friendly account metadata after a successful token exchange.
@ -86,7 +92,8 @@ async def _fetch_account_metadata(
meta["display_name"] = whoami.get("email", "Airtable")
else:
logger.warning(
"Airtable whoami API returned %d (non-blocking)", resp.status_code,
"Airtable whoami API returned %d (non-blocking)",
resp.status_code,
)
except Exception:
@ -98,6 +105,7 @@ async def _fetch_account_metadata(
return meta
_state_manager: OAuthStateManager | None = None
_token_encryption: TokenEncryption | None = None
@ -151,6 +159,7 @@ def _frontend_redirect(
# /add — start MCP OAuth flow
# ---------------------------------------------------------------------------
@router.get("/auth/mcp/{service}/connector/add")
async def connect_mcp_service(
service: str,
@ -170,9 +179,12 @@ async def connect_mcp_service(
)
metadata = await discover_oauth_metadata(
svc.mcp_url, origin_override=svc.oauth_discovery_origin,
svc.mcp_url,
origin_override=svc.oauth_discovery_origin,
)
auth_endpoint = svc.auth_endpoint_override or metadata.get(
"authorization_endpoint"
)
auth_endpoint = svc.auth_endpoint_override or metadata.get("authorization_endpoint")
token_endpoint = svc.token_endpoint_override or metadata.get("token_endpoint")
registration_endpoint = metadata.get("registration_endpoint")
@ -236,7 +248,9 @@ async def connect_mcp_service(
logger.info(
"Generated %s MCP OAuth URL for user %s, space %s",
svc.name, user.id, space_id,
svc.name,
user.id,
space_id,
)
return {"auth_url": auth_url}
@ -245,7 +259,8 @@ async def connect_mcp_service(
except Exception as e:
logger.error("Failed to initiate %s MCP OAuth: %s", service, e, exc_info=True)
raise HTTPException(
status_code=500, detail=f"Failed to initiate {service} MCP OAuth.",
status_code=500,
detail=f"Failed to initiate {service} MCP OAuth.",
) from e
@ -253,6 +268,7 @@ async def connect_mcp_service(
# /callback — handle OAuth redirect
# ---------------------------------------------------------------------------
@router.get("/auth/mcp/{service}/connector/callback")
async def mcp_oauth_callback(
service: str,
@ -271,7 +287,9 @@ async def mcp_oauth_callback(
except Exception:
pass
return _frontend_redirect(
space_id, error=f"{service}_mcp_oauth_denied", service=service,
space_id,
error=f"{service}_mcp_oauth_denied",
service=service,
)
if not code:
@ -337,9 +355,7 @@ async def mcp_oauth_callback(
expires_at = None
if expires_in:
expires_at = datetime.now(UTC) + timedelta(
seconds=int(expires_in)
)
expires_at = datetime.now(UTC) + timedelta(seconds=int(expires_in))
connector_config = {
"server_config": {
@ -349,10 +365,14 @@ async def mcp_oauth_callback(
"mcp_service": svc_key,
"mcp_oauth": {
"client_id": client_id,
"client_secret": enc.encrypt_token(client_secret) if client_secret else "",
"client_secret": enc.encrypt_token(client_secret)
if client_secret
else "",
"token_endpoint": token_endpoint,
"access_token": enc.encrypt_token(access_token),
"refresh_token": enc.encrypt_token(refresh_token) if refresh_token else None,
"refresh_token": enc.encrypt_token(refresh_token)
if refresh_token
else None,
"expires_at": expires_at.isoformat() if expires_at else None,
"scope": scope,
},
@ -361,15 +381,27 @@ async def mcp_oauth_callback(
account_meta = await _fetch_account_metadata(svc_key, access_token, token_json)
if account_meta:
_SAFE_META_KEYS = {"display_name", "team_id", "team_name", "user_id", "user_email",
"workspace_id", "workspace_name", "organization_name",
"organization_url_key", "cloud_id", "site_name", "base_url"}
safe_meta_keys = {
"display_name",
"team_id",
"team_name",
"user_id",
"user_email",
"workspace_id",
"workspace_name",
"organization_name",
"organization_url_key",
"cloud_id",
"site_name",
"base_url",
}
for k, v in account_meta.items():
if k in _SAFE_META_KEYS:
if k in safe_meta_keys:
connector_config[k] = v
logger.info(
"Stored account metadata for %s: display_name=%s",
svc_key, account_meta.get("display_name", ""),
svc_key,
account_meta.get("display_name", ""),
)
# ---- Re-auth path ----
@ -400,15 +432,24 @@ async def mcp_oauth_callback(
logger.info(
"Re-authenticated %s MCP connector %s for user %s",
svc.name, db_connector.id, user_id,
svc.name,
db_connector.id,
user_id,
)
reauth_return_url = data.get("return_url")
if reauth_return_url and reauth_return_url.startswith("/") and not reauth_return_url.startswith("//"):
if (
reauth_return_url
and reauth_return_url.startswith("/")
and not reauth_return_url.startswith("//")
):
return RedirectResponse(
url=f"{config.NEXT_FRONTEND_URL}{reauth_return_url}"
)
return _frontend_redirect(
space_id, success=True, connector_id=db_connector.id, service=service,
space_id,
success=True,
connector_id=db_connector.id,
service=service,
)
# ---- New connector path ----
@ -436,24 +477,34 @@ async def mcp_oauth_callback(
except IntegrityError as e:
await session.rollback()
raise HTTPException(
status_code=409, detail="A connector for this service already exists.",
status_code=409,
detail="A connector for this service already exists.",
) from e
_invalidate_cache(space_id)
logger.info(
"Created %s MCP connector %s for user %s in space %s",
svc.name, new_connector.id, user_id, space_id,
svc.name,
new_connector.id,
user_id,
space_id,
)
return _frontend_redirect(
space_id, success=True, connector_id=new_connector.id, service=service,
space_id,
success=True,
connector_id=new_connector.id,
service=service,
)
except HTTPException:
raise
except Exception as e:
logger.error(
"Failed to complete %s MCP OAuth: %s", service, e, exc_info=True,
"Failed to complete %s MCP OAuth: %s",
service,
e,
exc_info=True,
)
raise HTTPException(
status_code=500,
@ -465,6 +516,7 @@ async def mcp_oauth_callback(
# /reauth — re-authenticate an existing MCP connector
# ---------------------------------------------------------------------------
@router.get("/auth/mcp/{service}/connector/reauth")
async def reauth_mcp_service(
service: str,
@ -491,7 +543,8 @@ async def reauth_mcp_service(
)
if not result.scalars().first():
raise HTTPException(
status_code=404, detail="Connector not found or access denied",
status_code=404,
detail="Connector not found or access denied",
)
try:
@ -501,9 +554,12 @@ async def reauth_mcp_service(
)
metadata = await discover_oauth_metadata(
svc.mcp_url, origin_override=svc.oauth_discovery_origin,
svc.mcp_url,
origin_override=svc.oauth_discovery_origin,
)
auth_endpoint = svc.auth_endpoint_override or metadata.get(
"authorization_endpoint"
)
auth_endpoint = svc.auth_endpoint_override or metadata.get("authorization_endpoint")
token_endpoint = svc.token_endpoint_override or metadata.get("token_endpoint")
registration_endpoint = metadata.get("registration_endpoint")
@ -545,7 +601,9 @@ async def reauth_mcp_service(
"service": service,
"code_verifier": verifier,
"mcp_client_id": client_id,
"mcp_client_secret": enc.encrypt_token(client_secret) if client_secret else "",
"mcp_client_secret": enc.encrypt_token(client_secret)
if client_secret
else "",
"mcp_token_endpoint": token_endpoint,
"mcp_url": svc.mcp_url,
"connector_id": connector_id,
@ -554,7 +612,9 @@ async def reauth_mcp_service(
extra["return_url"] = return_url
state = _get_state_manager().generate_secure_state(
space_id, user.id, **extra,
space_id,
user.id,
**extra,
)
auth_params: dict[str, str] = {
@ -572,7 +632,9 @@ async def reauth_mcp_service(
logger.info(
"Initiating %s MCP re-auth for user %s, connector %s",
svc.name, user.id, connector_id,
svc.name,
user.id,
connector_id,
)
return {"auth_url": auth_url}
@ -580,7 +642,10 @@ async def reauth_mcp_service(
raise
except Exception as e:
logger.error(
"Failed to initiate %s MCP re-auth: %s", service, e, exc_info=True,
"Failed to initiate %s MCP re-auth: %s",
service,
e,
exc_info=True,
)
raise HTTPException(
status_code=500,
@ -592,6 +657,7 @@ async def reauth_mcp_service(
# Helpers
# ---------------------------------------------------------------------------
def _invalidate_cache(space_id: int) -> None:
try:
from app.agents.new_chat.tools.mcp_tool import invalidate_mcp_tools_cache

View file

@ -24,9 +24,9 @@ from sqlalchemy.orm import selectinload
from app.agents.new_chat.filesystem_selection import (
ClientPlatform,
LocalFilesystemMount,
FilesystemMode,
FilesystemSelection,
LocalFilesystemMount,
)
from app.config import config
from app.db import (

View file

@ -9,6 +9,7 @@ Call ``build_router()`` to get a FastAPI ``APIRouter`` with ``/connector/add``,
from __future__ import annotations
import base64
import contextlib
import logging
from datetime import UTC, datetime, timedelta
from typing import Any
@ -41,7 +42,6 @@ logger = logging.getLogger(__name__)
class OAuthConnectorRoute:
def __init__(
self,
*,
@ -244,10 +244,8 @@ class OAuthConnectorRoute:
if resp.status_code != 200:
detail = resp.text
try:
with contextlib.suppress(Exception):
detail = resp.json().get("error_description", detail)
except Exception:
pass
raise HTTPException(
status_code=400, detail=f"Token exchange failed: {detail}"
)
@ -430,7 +428,11 @@ class OAuthConnectorRoute:
state_mgr = oauth._get_state_manager()
extra: dict[str, Any] = {"connector_id": connector_id}
if return_url and return_url.startswith("/") and not return_url.startswith("//"):
if (
return_url
and return_url.startswith("/")
and not return_url.startswith("//")
):
extra["return_url"] = return_url
auth_params: dict[str, str] = {
@ -450,9 +452,7 @@ class OAuthConnectorRoute:
auth_params.update(oauth.extra_auth_params)
state_encoded = state_mgr.generate_secure_state(
space_id, user.id, **extra
)
state_encoded = state_mgr.generate_secure_state(space_id, user.id, **extra)
auth_params["state"] = state_encoded
auth_url = f"{oauth.authorize_url}?{urlencode(auth_params)}"
@ -489,9 +489,7 @@ class OAuthConnectorRoute:
status_code=400, detail="Missing authorization code"
)
if not state:
raise HTTPException(
status_code=400, detail="Missing state parameter"
)
raise HTTPException(status_code=400, detail="Missing state parameter")
state_mgr = oauth._get_state_manager()
try:
@ -552,7 +550,11 @@ class OAuthConnectorRoute:
db_connector.id,
user_id,
)
if reauth_return_url and reauth_return_url.startswith("/") and not reauth_return_url.startswith("//"):
if (
reauth_return_url
and reauth_return_url.startswith("/")
and not reauth_return_url.startswith("//")
):
return RedirectResponse(
url=f"{config.NEXT_FRONTEND_URL}{reauth_return_url}"
)
@ -603,7 +605,8 @@ class OAuthConnectorRoute:
except IntegrityError as e:
await session.rollback()
raise HTTPException(
status_code=409, detail="A connector for this service already exists."
status_code=409,
detail="A connector for this service already exists.",
) from e
logger.info(

View file

@ -3092,7 +3092,7 @@ async def trust_mcp_tool(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user.id,
cast(SearchSourceConnector.config, PG_JSONB).has_key("server_config"), # noqa: W601
cast(SearchSourceConnector.config, PG_JSONB).has_key("server_config"),
)
)
connector = result.scalars().first()
@ -3147,7 +3147,7 @@ async def untrust_mcp_tool(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user.id,
cast(SearchSourceConnector.config, PG_JSONB).has_key("server_config"), # noqa: W601
cast(SearchSourceConnector.config, PG_JSONB).has_key("server_config"),
)
)
connector = result.scalars().first()

View file

@ -55,7 +55,9 @@ async def register_client(
async with httpx.AsyncClient(follow_redirects=True) as client:
resp = await client.post(
registration_endpoint, json=payload, timeout=timeout,
registration_endpoint,
json=payload,
timeout=timeout,
)
resp.raise_for_status()
return resp.json()

View file

@ -70,12 +70,14 @@ MCP_SERVICES: dict[str, MCPServiceConfig] = {
"createJiraIssue",
"editJiraIssue",
],
readonly_tools=frozenset({
"getAccessibleAtlassianResources",
"searchJiraIssuesUsingJql",
"getVisibleJiraProjects",
"getJiraProjectIssueTypesMetadata",
}),
readonly_tools=frozenset(
{
"getAccessibleAtlassianResources",
"searchJiraIssuesUsingJql",
"getVisibleJiraProjects",
"getJiraProjectIssueTypesMetadata",
}
),
account_metadata_keys=["cloud_id", "site_name", "base_url"],
),
"clickup": MCPServiceConfig(
@ -99,15 +101,23 @@ MCP_SERVICES: dict[str, MCPServiceConfig] = {
auth_endpoint_override="https://slack.com/oauth/v2_user/authorize",
token_endpoint_override="https://slack.com/api/oauth.v2.user.access",
scopes=[
"search:read.public", "search:read.private", "search:read.mpim", "search:read.im",
"channels:history", "groups:history", "mpim:history", "im:history",
"search:read.public",
"search:read.private",
"search:read.mpim",
"search:read.im",
"channels:history",
"groups:history",
"mpim:history",
"im:history",
],
allowed_tools=[
"slack_search_channels",
"slack_read_channel",
"slack_read_thread",
],
readonly_tools=frozenset({"slack_search_channels", "slack_read_channel", "slack_read_thread"}),
readonly_tools=frozenset(
{"slack_search_channels", "slack_read_channel", "slack_read_thread"}
),
# TODO: oauth.v2.user.access only returns team.id, not team.name.
# To populate team_name, either add "team:read" scope and call
# GET /api/team.info during OAuth callback, or switch to oauth.v2.access.
@ -127,7 +137,9 @@ MCP_SERVICES: dict[str, MCPServiceConfig] = {
"list_tables_for_base",
"list_records_for_table",
],
readonly_tools=frozenset({"list_bases", "list_tables_for_base", "list_records_for_table"}),
readonly_tools=frozenset(
{"list_bases", "list_tables_for_base", "list_records_for_table"}
),
account_metadata_keys=["user_id", "user_email"],
),
}
@ -136,20 +148,22 @@ _CONNECTOR_TYPE_TO_SERVICE: dict[str, MCPServiceConfig] = {
svc.connector_type: svc for svc in MCP_SERVICES.values()
}
LIVE_CONNECTOR_TYPES: frozenset[SearchSourceConnectorType] = frozenset({
SearchSourceConnectorType.SLACK_CONNECTOR,
SearchSourceConnectorType.TEAMS_CONNECTOR,
SearchSourceConnectorType.LINEAR_CONNECTOR,
SearchSourceConnectorType.JIRA_CONNECTOR,
SearchSourceConnectorType.CLICKUP_CONNECTOR,
SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR,
SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR,
SearchSourceConnectorType.AIRTABLE_CONNECTOR,
SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR,
SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR,
SearchSourceConnectorType.DISCORD_CONNECTOR,
SearchSourceConnectorType.LUMA_CONNECTOR,
})
LIVE_CONNECTOR_TYPES: frozenset[SearchSourceConnectorType] = frozenset(
{
SearchSourceConnectorType.SLACK_CONNECTOR,
SearchSourceConnectorType.TEAMS_CONNECTOR,
SearchSourceConnectorType.LINEAR_CONNECTOR,
SearchSourceConnectorType.JIRA_CONNECTOR,
SearchSourceConnectorType.CLICKUP_CONNECTOR,
SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR,
SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR,
SearchSourceConnectorType.AIRTABLE_CONNECTOR,
SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR,
SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR,
SearchSourceConnectorType.DISCORD_CONNECTOR,
SearchSourceConnectorType.LUMA_CONNECTOR,
}
)
def get_service(key: str) -> MCPServiceConfig | None:

View file

@ -156,7 +156,9 @@ async def _extract_binary_attachment_markdown(
try:
raw_bytes = base64.b64decode(payload.binary_base64, validate=True)
except Exception:
logger.warning("obsidian attachment payload had invalid base64: %s", payload.path)
logger.warning(
"obsidian attachment payload had invalid base64: %s", payload.path
)
return "", {"attachment_extraction_status": "invalid_binary_payload"}
suffix = f".{payload.extension.lstrip('.')}"
@ -180,7 +182,10 @@ async def _extract_binary_attachment_markdown(
return result.markdown_content, metadata
except Exception as exc:
logger.warning(
"obsidian attachment ETL failed for %s: %s", payload.path, exc, exc_info=True
"obsidian attachment ETL failed for %s: %s",
payload.path,
exc,
exc_info=True,
)
return "", {
"attachment_extraction_status": "etl_failed",

View file

@ -31,7 +31,6 @@ from sqlalchemy.orm import selectinload
from app.agents.new_chat.chat_deepagent import create_surfsense_deep_agent
from app.agents.new_chat.checkpointer import get_checkpointer
from app.agents.new_chat.filesystem_selection import FilesystemSelection
from app.config import config
from app.agents.new_chat.llm_config import (
AgentConfig,
create_chat_litellm_from_agent_config,
@ -182,9 +181,9 @@ def _tool_output_has_error(tool_output: Any) -> bool:
if tool_output.get("error"):
return True
result = tool_output.get("result")
if isinstance(result, str) and result.strip().lower().startswith("error:"):
return True
return False
return bool(
isinstance(result, str) and result.strip().lower().startswith("error:")
)
if isinstance(tool_output, str):
return tool_output.strip().lower().startswith("error:")
return False
@ -230,7 +229,9 @@ def _log_file_contract(stage: str, result: StreamResult, **extra: Any) -> None:
"stage": stage,
"request_id": result.request_id or "unknown",
"turn_id": result.turn_id or "unknown",
"chat_id": result.turn_id.split(":", 1)[0] if ":" in result.turn_id else "unknown",
"chat_id": result.turn_id.split(":", 1)[0]
if ":" in result.turn_id
else "unknown",
"filesystem_mode": result.filesystem_mode,
"client_platform": result.client_platform,
"intent_detected": result.intent_detected,
@ -242,7 +243,9 @@ def _log_file_contract(stage: str, result: StreamResult, **extra: Any) -> None:
"commit_gate_reason": result.commit_gate_reason or None,
}
payload.update(extra)
_perf_log.info("[file_operation_contract] %s", json.dumps(payload, ensure_ascii=False))
_perf_log.info(
"[file_operation_contract] %s", json.dumps(payload, ensure_ascii=False)
)
async def _stream_agent_events(
@ -1289,7 +1292,8 @@ async def _stream_agent_events(
result.intent_detected = intent_value
if (
isinstance(intent_value, str)
and intent_value in (
and intent_value
in (
"chat_only",
"file_write",
"file_read",
@ -1308,18 +1312,17 @@ async def _stream_agent_events(
result.commit_gate_passed, result.commit_gate_reason = (
_evaluate_file_contract_outcome(result)
)
if not result.commit_gate_passed:
if _contract_enforcement_active(result):
gate_notice = (
"I could not complete the requested file write because no successful "
"write_file/edit_file operation was confirmed."
)
gate_text_id = streaming_service.generate_text_id()
yield streaming_service.format_text_start(gate_text_id)
yield streaming_service.format_text_delta(gate_text_id, gate_notice)
yield streaming_service.format_text_end(gate_text_id)
yield streaming_service.format_terminal_info(gate_notice, "error")
accumulated_text = gate_notice
if not result.commit_gate_passed and _contract_enforcement_active(result):
gate_notice = (
"I could not complete the requested file write because no successful "
"write_file/edit_file operation was confirmed."
)
gate_text_id = streaming_service.generate_text_id()
yield streaming_service.format_text_start(gate_text_id)
yield streaming_service.format_text_delta(gate_text_id, gate_notice)
yield streaming_service.format_text_end(gate_text_id)
yield streaming_service.format_terminal_info(gate_notice, "error")
accumulated_text = gate_notice
else:
result.commit_gate_passed = True
result.commit_gate_reason = ""

View file

@ -2,6 +2,7 @@
from __future__ import annotations
import contextlib
import logging
from collections.abc import Callable
from typing import TypeVar
@ -32,9 +33,7 @@ F = TypeVar("F", bound=Callable)
def _is_retryable(exc: BaseException) -> bool:
if isinstance(exc, ConnectorError):
return exc.retryable
if isinstance(exc, (httpx.TimeoutException, httpx.ConnectError)):
return True
return False
return bool(isinstance(exc, (httpx.TimeoutException, httpx.ConnectError)))
def build_retry(
@ -86,10 +85,8 @@ def raise_for_status(
retry_after_raw = response.headers.get("Retry-After")
retry_after: float | None = None
if retry_after_raw:
try:
with contextlib.suppress(ValueError, TypeError):
retry_after = float(retry_after_raw)
except (ValueError, TypeError):
pass
raise ConnectorRateLimitError(
f"{service} rate limited (429)",
service=service,

View file

@ -233,7 +233,10 @@ async def generate_unique_connector_name(
if identifier:
name = f"{base} - {identifier}"
return await ensure_unique_connector_name(
session, name, search_space_id, user_id,
session,
name,
search_space_id,
user_id,
)
count = await count_connectors_of_type(

View file

@ -499,7 +499,9 @@ class TestWireContractSmoke:
"app.routes.obsidian_plugin_routes.upsert_note",
new=AsyncMock(return_value=fake_doc),
) as upsert_mock,
patch("app.routes.obsidian_plugin_routes._queue_obsidian_attachment") as queue_mock,
patch(
"app.routes.obsidian_plugin_routes._queue_obsidian_attachment"
) as queue_mock,
):
sync_resp = await obsidian_sync(
SyncBatchRequest(
@ -548,7 +550,9 @@ class TestWireContractSmoke:
"app.routes.obsidian_plugin_routes.upsert_note",
new=AsyncMock(return_value=fake_doc),
),
patch("app.routes.obsidian_plugin_routes._queue_obsidian_attachment") as queue_mock,
patch(
"app.routes.obsidian_plugin_routes._queue_obsidian_attachment"
) as queue_mock,
):
sync_resp = await obsidian_sync(
SyncBatchRequest(
@ -600,7 +604,9 @@ class TestWireContractSmoke:
"app.routes.obsidian_plugin_routes.upsert_note",
new=AsyncMock(return_value=fake_doc),
),
patch("app.routes.obsidian_plugin_routes._queue_obsidian_attachment") as queue_mock,
patch(
"app.routes.obsidian_plugin_routes._queue_obsidian_attachment"
) as queue_mock,
):
sync_resp = await obsidian_sync(
SyncBatchRequest(
@ -619,7 +625,5 @@ class TestWireContractSmoke:
items_by_path = {it.path: it for it in sync_resp.items}
assert items_by_path["ok.md"].status == "ok"
assert items_by_path["image.png"].status == "error"
assert "does not match extension" in (
items_by_path["image.png"].error or ""
)
assert "does not match extension" in (items_by_path["image.png"].error or "")
queue_mock.assert_not_called()

View file

@ -45,9 +45,7 @@ async def test_file_write_intent_injects_contract_message():
@pytest.mark.asyncio
async def test_non_write_intent_does_not_inject_contract_message():
llm = _FakeLLM(
'{"intent":"file_read","confidence":0.88,"suggested_filename":null}'
)
llm = _FakeLLM('{"intent":"file_read","confidence":0.88,"suggested_filename":null}')
middleware = FileIntentMiddleware(llm=llm)
original_messages = [HumanMessage(content="Read /notes.md")]
state = {"messages": original_messages, "turn_id": "abc:def"}
@ -55,7 +53,10 @@ async def test_non_write_intent_does_not_inject_contract_message():
result = await middleware.abefore_agent(state, runtime=None) # type: ignore[arg-type]
assert result is not None
assert result["file_operation_contract"]["intent"] == FileOperationIntent.FILE_READ.value
assert (
result["file_operation_contract"]["intent"]
== FileOperationIntent.FILE_READ.value
)
assert "messages" not in result
@ -211,4 +212,3 @@ def test_fallback_path_keeps_posix_style_absolute_path_for_linux_and_macos() ->
)
assert resolved == "/var/log/surfsense/notes.md"

View file

@ -2,11 +2,11 @@ from pathlib import Path
import pytest
from app.agents.new_chat.filesystem_selection import FilesystemMode
from app.agents.new_chat.middleware.filesystem import SurfSenseFilesystemMiddleware
from app.agents.new_chat.middleware.multi_root_local_folder_backend import (
MultiRootLocalFolderBackend,
)
from app.agents.new_chat.filesystem_selection import FilesystemMode
from app.agents.new_chat.middleware.filesystem import SurfSenseFilesystemMiddleware
pytestmark = pytest.mark.unit

View file

@ -15,7 +15,6 @@ from app.services.obsidian_plugin_indexer import (
_require_extracted_attachment_content,
)
_FAKE_PNG_B64 = base64.b64encode(b"\x89PNG\r\n\x1a\n").decode("ascii")
@ -102,9 +101,7 @@ async def test_extract_binary_attachment_markdown_uses_etl(monkeypatch) -> None:
mime_type="application/pdf",
)
async def _fake_run_etl_extract( # noqa: ANN001
*, file_path, filename, vision_llm
):
async def _fake_run_etl_extract(*, file_path, filename, vision_llm):
assert filename == "spec.pdf"
assert file_path
assert vision_llm is None
@ -216,7 +213,7 @@ def test_note_payload_rejects_markdown_with_binary_fields() -> None:
def test_require_extracted_attachment_content_rejects_empty_content() -> None:
with pytest.raises(
RuntimeError, match="Attachment extraction failed for assets/img.png"
RuntimeError, match=r"Attachment extraction failed for assets/img\.png"
):
_require_extracted_attachment_content(
content=" ",

View file

@ -45,4 +45,3 @@ def test_contract_enforcement_local_only():
result.filesystem_mode = "cloud"
assert not _contract_enforcement_active(result)

View file

@ -45,8 +45,8 @@ import {
} from "@/components/assistant-ui/token-usage-context";
import { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
import { useMessagesSync } from "@/hooks/use-messages-sync";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { getAgentFilesystemSelection } from "@/lib/agent-filesystem";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { getBearerToken } from "@/lib/auth-utils";
import { convertToThreadMessage } from "@/lib/chat/message-utils";
import {
@ -661,8 +661,7 @@ export default function NewChatPage() {
const selection = await getAgentFilesystemSelection(searchSpaceId);
if (
selection.filesystem_mode === "desktop_local_folder" &&
(!selection.local_filesystem_mounts ||
selection.local_filesystem_mounts.length === 0)
(!selection.local_filesystem_mounts || selection.local_filesystem_mounts.length === 0)
) {
toast.error("Select a local folder before using Local Folder mode.");
return;
@ -842,14 +841,7 @@ export default function NewChatPage() {
});
} else {
const tcId = `interrupt-${action.name}`;
addToolCall(
contentPartsState,
toolsWithUI,
tcId,
action.name,
action.args,
true
);
addToolCall(contentPartsState, toolsWithUI, tcId, action.name, action.args, true);
updateToolCall(contentPartsState, tcId, {
result: { __interrupt__: true, ...interruptData },
});
@ -1189,14 +1181,7 @@ export default function NewChatPage() {
});
} else {
const tcId = `interrupt-${action.name}`;
addToolCall(
contentPartsState,
toolsWithUI,
tcId,
action.name,
action.args,
true
);
addToolCall(contentPartsState, toolsWithUI, tcId, action.name, action.args, true);
updateToolCall(contentPartsState, tcId, {
result: {
__interrupt__: true,

View file

@ -111,9 +111,7 @@ function HotkeyRow({
}
>
{recording ? (
<span className="px-2 text-[9px] text-primary whitespace-nowrap">
Press hotkeys...
</span>
<span className="px-2 text-[9px] text-primary whitespace-nowrap">Press hotkeys...</span>
) : (
<ShortcutKbd keys={displayKeys} className="ml-0 px-1.5 text-foreground/85" />
)}
@ -155,7 +153,9 @@ export function DesktopShortcutsContent() {
if (!api) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-sm text-muted-foreground">Hotkeys are only available in the SurfSense desktop app.</p>
<p className="text-sm text-muted-foreground">
Hotkeys are only available in the SurfSense desktop app.
</p>
</div>
);
}
@ -178,28 +178,26 @@ export function DesktopShortcutsContent() {
updateShortcut(key, DEFAULT_SHORTCUTS[key]);
};
return (
shortcutsLoaded ? (
<div className="flex flex-col gap-3">
<div>
{HOTKEY_ROWS.map((row) => (
<HotkeyRow
key={row.key}
label={row.label}
value={shortcuts[row.key]}
defaultValue={DEFAULT_SHORTCUTS[row.key]}
icon={row.icon}
isMac={isMac}
onChange={(accel) => updateShortcut(row.key, accel)}
onReset={() => resetShortcut(row.key)}
/>
))}
</div>
return shortcutsLoaded ? (
<div className="flex flex-col gap-3">
<div>
{HOTKEY_ROWS.map((row) => (
<HotkeyRow
key={row.key}
label={row.label}
value={shortcuts[row.key]}
defaultValue={DEFAULT_SHORTCUTS[row.key]}
icon={row.icon}
isMac={isMac}
onChange={(accel) => updateShortcut(row.key, accel)}
onReset={() => resetShortcut(row.key)}
/>
))}
</div>
) : (
<div className="flex justify-center py-4">
<Spinner size="sm" />
</div>
)
</div>
) : (
<div className="flex justify-center py-4">
<Spinner size="sm" />
</div>
);
}

View file

@ -24,7 +24,12 @@ const isGoogleAuth = AUTH_TYPE === "GOOGLE";
type ShortcutKey = "generalAssist" | "quickAsk" | "autocomplete";
type ShortcutMap = typeof DEFAULT_SHORTCUTS;
const HOTKEY_ROWS: Array<{ key: ShortcutKey; label: string; description: string; icon: React.ElementType }> = [
const HOTKEY_ROWS: Array<{
key: ShortcutKey;
label: string;
description: string;
icon: React.ElementType;
}> = [
{
key: "generalAssist",
label: "General Assist",
@ -369,7 +374,9 @@ export default function DesktopLoginPage() {
<Button type="submit" disabled={isLoggingIn} className="relative h-9 mt-1">
<span className={isLoggingIn ? "opacity-0" : ""}>Sign in</span>
{isLoggingIn && <Spinner size="sm" className="absolute text-primary-foreground" />}
{isLoggingIn && (
<Spinner size="sm" className="absolute text-primary-foreground" />
)}
</Button>
</form>
)}

View file

@ -123,9 +123,9 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
handleSkipIndexing,
handleStartEdit,
handleSaveConnector,
handleDisconnectConnector,
handleDisconnectFromList,
handleBackFromEdit,
handleDisconnectConnector,
handleDisconnectFromList,
handleBackFromEdit,
handleBackFromConnect,
handleBackFromYouTube,
handleViewAccountsList,
@ -226,27 +226,31 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
{isYouTubeView && searchSpaceId ? (
<YouTubeCrawlerView searchSpaceId={searchSpaceId} onBack={handleBackFromYouTube} />
) : viewingMCPList ? (
<ConnectorAccountsListView
connectorType="MCP_CONNECTOR"
connectorTitle="MCP Connectors"
connectors={(allConnectors || []) as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
onBack={handleBackFromMCPList}
onManage={handleStartEdit}
onDisconnect={(connector) => handleDisconnectFromList(connector, () => refreshConnectors())}
onAddAccount={handleAddNewMCPFromList}
addButtonText="Add New MCP Server"
/>
<ConnectorAccountsListView
connectorType="MCP_CONNECTOR"
connectorTitle="MCP Connectors"
connectors={(allConnectors || []) as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
onBack={handleBackFromMCPList}
onManage={handleStartEdit}
onDisconnect={(connector) =>
handleDisconnectFromList(connector, () => refreshConnectors())
}
onAddAccount={handleAddNewMCPFromList}
addButtonText="Add New MCP Server"
/>
) : viewingAccountsType ? (
<ConnectorAccountsListView
connectorType={viewingAccountsType.connectorType}
connectorTitle={viewingAccountsType.connectorTitle}
connectors={(connectors || []) as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
onBack={handleBackFromAccountsList}
onManage={handleStartEdit}
onDisconnect={(connector) => handleDisconnectFromList(connector, () => refreshConnectors())}
onAddAccount={() => {
<ConnectorAccountsListView
connectorType={viewingAccountsType.connectorType}
connectorTitle={viewingAccountsType.connectorTitle}
connectors={(connectors || []) as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
onBack={handleBackFromAccountsList}
onManage={handleStartEdit}
onDisconnect={(connector) =>
handleDisconnectFromList(connector, () => refreshConnectors())
}
onAddAccount={() => {
// Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
const oauthConnector =
OAUTH_CONNECTORS.find(

View file

@ -213,13 +213,13 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
className="w-full h-8 text-[13px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80"
>
{isTesting ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Testing Connection...
</>
) : (
"Test Connection"
)}
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Testing Connection...
</>
) : (
"Test Connection"
)}
</Button>
</div>

View file

@ -218,13 +218,13 @@ export const MCPConfig: FC<MCPConfigProps> = ({ connector, onConfigChange, onNam
className="w-full h-8 text-[13px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80"
>
{isTesting ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Testing Connection...
</>
) : (
"Test Connection"
)}
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Testing Connection...
</>
) : (
"Test Connection"
)}
</Button>
</div>

View file

@ -18,9 +18,9 @@ export const TeamsConfig: FC<TeamsConfigProps> = () => {
<div className="text-xs sm:text-sm">
<p className="font-medium text-xs sm:text-sm">Microsoft Teams Access</p>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
Your agent can search and read messages from Teams channels you have access to,
and send messages on your behalf. Make sure you&#39;re a member of the teams
you want to interact with.
Your agent can search and read messages from Teams channels you have access to, and send
messages on your behalf. Make sure you&#39;re a member of the teams you want to interact
with.
</p>
</div>
</div>

View file

@ -16,7 +16,7 @@ import { DateRangeSelector } from "../../components/date-range-selector";
import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
import { SummaryConfig } from "../../components/summary-config";
import { VisionLLMConfig } from "../../components/vision-llm-config";
import { LIVE_CONNECTOR_TYPES, getReauthEndpoint } from "../../constants/connector-constants";
import { getReauthEndpoint, LIVE_CONNECTOR_TYPES } from "../../constants/connector-constants";
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
import { MCPServiceConfig } from "../components/mcp-service-config";
import { getConnectorConfigComponent } from "../index";
@ -314,8 +314,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
{connector.is_indexable &&
(() => {
const isGoogleDrive =
connector.connector_type === "GOOGLE_DRIVE_CONNECTOR";
const isGoogleDrive = connector.connector_type === "GOOGLE_DRIVE_CONNECTOR";
const isComposioGoogleDrive =
connector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR";
const requiresFolderSelection = isGoogleDrive || isComposioGoogleDrive;
@ -327,8 +326,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
(connector.config?.selected_files as
| Array<{ id: string; name: string }>
| undefined) || [];
const hasItemsSelected =
selectedFolders.length > 0 || selectedFiles.length > 0;
const hasItemsSelected = selectedFolders.length > 0 || selectedFiles.length > 0;
const isDisabled = requiresFolderSelection && !hasItemsSelected;
return (
@ -380,8 +378,8 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
{/* Fixed Footer - Action buttons */}
<div className="flex-shrink-0 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 sm:gap-0 px-6 sm:px-12 py-6 sm:py-6 bg-muted border-t border-border">
{showDisconnectConfirm ? (
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 flex-1 sm:flex-initial">
{showDisconnectConfirm ? (
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 flex-1 sm:flex-initial">
<span className="text-xs sm:text-sm text-muted-foreground sm:whitespace-nowrap">
{isLive
? "Your agent will lose access to this service."

View file

@ -12,7 +12,10 @@ import { DateRangeSelector } from "../../components/date-range-selector";
import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
import { SummaryConfig } from "../../components/summary-config";
import { VisionLLMConfig } from "../../components/vision-llm-config";
import { LIVE_CONNECTOR_TYPES, type IndexingConfigState } from "../../constants/connector-constants";
import {
type IndexingConfigState,
LIVE_CONNECTOR_TYPES,
} from "../../constants/connector-constants";
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
import { getConnectorConfigComponent } from "../index";

View file

@ -9,7 +9,11 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels";
import { cn } from "@/lib/utils";
import { COMPOSIO_CONNECTORS, LIVE_CONNECTOR_TYPES, OAUTH_CONNECTORS } from "../constants/connector-constants";
import {
COMPOSIO_CONNECTORS,
LIVE_CONNECTOR_TYPES,
OAUTH_CONNECTORS,
} from "../constants/connector-constants";
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
import { getConnectorDisplayName } from "./all-connectors-tab";

View file

@ -13,7 +13,7 @@ import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { authenticatedFetch } from "@/lib/auth-utils";
import { formatRelativeDate } from "@/lib/format-date";
import { cn } from "@/lib/utils";
import { LIVE_CONNECTOR_TYPES, getReauthEndpoint } from "../constants/connector-constants";
import { getReauthEndpoint, LIVE_CONNECTOR_TYPES } from "../constants/connector-constants";
import { useConnectorStatus } from "../hooks/use-connector-status";
import { getConnectorDisplayName } from "../tabs/all-connectors-tab";
@ -182,11 +182,14 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{typeConnectors.map((connector) => {
const isIndexing = indexingConnectorIds.has(connector.id);
const connectorReauthEndpoint = getReauthEndpoint(connector);
const isAuthExpired = !!connectorReauthEndpoint && connector.config?.auth_expired === true;
const isLive = LIVE_CONNECTOR_TYPES.has(connector.connector_type) || Boolean(connector.config?.server_config);
{typeConnectors.map((connector) => {
const isIndexing = indexingConnectorIds.has(connector.id);
const connectorReauthEndpoint = getReauthEndpoint(connector);
const isAuthExpired =
!!connectorReauthEndpoint && connector.config?.auth_expired === true;
const isLive =
LIVE_CONNECTOR_TYPES.has(connector.connector_type) ||
Boolean(connector.config?.server_config);
return (
<div
@ -225,73 +228,73 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
</p>
) : null}
</div>
{isAuthExpired ? (
<Button
size="sm"
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-amber-600 hover:bg-amber-700 text-white border-0 shadow-xs shrink-0"
onClick={() => handleReauth(connector)}
disabled={reauthingId === connector.id}
>
<RefreshCw
className={cn("size-3.5", reauthingId === connector.id && "animate-spin")}
/>
Re-authenticate
</Button>
) : isLive && onDisconnect ? (
confirmDisconnectId === connector.id ? (
<div className="flex items-center gap-1.5 shrink-0">
{isAuthExpired ? (
<Button
size="sm"
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-amber-600 hover:bg-amber-700 text-white border-0 shadow-xs shrink-0"
onClick={() => handleReauth(connector)}
disabled={reauthingId === connector.id}
>
<RefreshCw
className={cn("size-3.5", reauthingId === connector.id && "animate-spin")}
/>
Re-authenticate
</Button>
) : isLive && onDisconnect ? (
confirmDisconnectId === connector.id ? (
<div className="flex items-center gap-1.5 shrink-0">
<Button
variant="destructive"
size="sm"
className="h-8 text-[11px] px-3 rounded-lg font-medium shadow-xs"
onClick={async () => {
setDisconnectingId(connector.id);
setConfirmDisconnectId(null);
try {
await onDisconnect(connector);
} finally {
setDisconnectingId(null);
}
}}
disabled={disconnectingId === connector.id}
>
{disconnectingId === connector.id ? (
<RefreshCw className="size-3.5 animate-spin" />
) : (
"Confirm"
)}
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 text-[11px] px-2 rounded-lg"
onClick={() => setConfirmDisconnectId(null)}
disabled={disconnectingId === connector.id}
>
Cancel
</Button>
</div>
) : (
<Button
variant="destructive"
variant="secondary"
size="sm"
className="h-8 text-[11px] px-3 rounded-lg font-medium shadow-xs"
onClick={async () => {
setDisconnectingId(connector.id);
setConfirmDisconnectId(null);
try {
await onDisconnect(connector);
} finally {
setDisconnectingId(null);
}
}}
disabled={disconnectingId === connector.id}
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-red-50 hover:text-red-700 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-red-950 dark:hover:text-red-400 shrink-0"
onClick={() => setConfirmDisconnectId(connector.id)}
>
{disconnectingId === connector.id ? (
<RefreshCw className="size-3.5 animate-spin" />
) : (
"Confirm"
)}
<Trash2 className="size-3.5" />
Disconnect
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 text-[11px] px-2 rounded-lg"
onClick={() => setConfirmDisconnectId(null)}
disabled={disconnectingId === connector.id}
>
Cancel
</Button>
</div>
)
) : (
<Button
variant="secondary"
size="sm"
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-red-50 hover:text-red-700 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-red-950 dark:hover:text-red-400 shrink-0"
onClick={() => setConfirmDisconnectId(connector.id)}
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80 shrink-0"
onClick={() => onManage(connector)}
>
<Trash2 className="size-3.5" />
Disconnect
Manage
</Button>
)
) : (
<Button
variant="secondary"
size="sm"
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80 shrink-0"
onClick={() => onManage(connector)}
>
Manage
</Button>
)}
)}
</div>
);
})}

View file

@ -20,7 +20,6 @@ import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { ImagePreview, ImageRoot, ImageZoom } from "@/components/assistant-ui/image";
import "katex/dist/katex.min.css";
import { InlineCitation, UrlCitation } from "@/components/assistant-ui/inline-citation";
import { useElectronAPI } from "@/hooks/use-platform";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
@ -30,6 +29,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useElectronAPI } from "@/hooks/use-platform";
import { cn } from "@/lib/utils";
function MarkdownCodeBlockSkeleton() {
@ -493,10 +493,7 @@ const defaultComponents = memoizeMarkdownComponents({
const mounts = (await electronAPI.getAgentFilesystemMounts(
resolvedSearchSpaceId
)) as AgentFilesystemMount[];
resolvedLocalPath = normalizeLocalVirtualPathForEditor(
inlineValue,
mounts
);
resolvedLocalPath = normalizeLocalVirtualPathForEditor(inlineValue, mounts);
} catch {
// Fall back to the raw inline path if mount lookup fails.
}

View file

@ -248,7 +248,15 @@ export function EditorPanelContent({
doFetch().catch(() => {});
return () => controller.abort();
}, [documentId, electronAPI, isLocalFileMode, localFilePath, resolveLocalVirtualPath, searchSpaceId, title]);
}, [
documentId,
electronAPI,
isLocalFileMode,
localFilePath,
resolveLocalVirtualPath,
searchSpaceId,
title,
]);
useEffect(() => {
return () => {
@ -282,69 +290,77 @@ export function EditorPanelContent({
}
}, [editorDoc?.source_markdown]);
const handleSave = useCallback(async (_options?: { silent?: boolean }) => {
setSaving(true);
try {
if (isLocalFileMode) {
if (!localFilePath) {
throw new Error("Missing local file path");
const handleSave = useCallback(
async (_options?: { silent?: boolean }) => {
setSaving(true);
try {
if (isLocalFileMode) {
if (!localFilePath) {
throw new Error("Missing local file path");
}
if (!electronAPI?.writeAgentLocalFileText) {
throw new Error("Local file editor is available only in desktop mode.");
}
const resolvedLocalPath = await resolveLocalVirtualPath(localFilePath);
const contentToSave = markdownRef.current;
const writeResult = await electronAPI.writeAgentLocalFileText(
resolvedLocalPath,
contentToSave,
searchSpaceId
);
if (!writeResult.ok) {
throw new Error(writeResult.error || "Failed to save local file");
}
setEditorDoc((prev) => (prev ? { ...prev, source_markdown: contentToSave } : prev));
setEditedMarkdown(markdownRef.current === contentToSave ? null : markdownRef.current);
return true;
}
if (!electronAPI?.writeAgentLocalFileText) {
throw new Error("Local file editor is available only in desktop mode.");
if (!searchSpaceId || !documentId) {
throw new Error("Missing document context");
}
const resolvedLocalPath = await resolveLocalVirtualPath(localFilePath);
const contentToSave = markdownRef.current;
const writeResult = await electronAPI.writeAgentLocalFileText(
resolvedLocalPath,
contentToSave,
searchSpaceId
const token = getBearerToken();
if (!token) {
toast.error("Please login to save");
redirectToLogin();
return;
}
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/save`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source_markdown: markdownRef.current }),
}
);
if (!writeResult.ok) {
throw new Error(writeResult.error || "Failed to save local file");
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ detail: "Failed to save document" }));
throw new Error(errorData.detail || "Failed to save document");
}
setEditorDoc((prev) =>
prev ? { ...prev, source_markdown: contentToSave } : prev
);
setEditedMarkdown(markdownRef.current === contentToSave ? null : markdownRef.current);
setEditorDoc((prev) => (prev ? { ...prev, source_markdown: markdownRef.current } : prev));
setEditedMarkdown(null);
toast.success("Document saved! Reindexing in background...");
return true;
} catch (err) {
console.error("Error saving document:", err);
toast.error(err instanceof Error ? err.message : "Failed to save document");
return false;
} finally {
setSaving(false);
}
if (!searchSpaceId || !documentId) {
throw new Error("Missing document context");
}
const token = getBearerToken();
if (!token) {
toast.error("Please login to save");
redirectToLogin();
return;
}
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/save`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source_markdown: markdownRef.current }),
}
);
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ detail: "Failed to save document" }));
throw new Error(errorData.detail || "Failed to save document");
}
setEditorDoc((prev) => (prev ? { ...prev, source_markdown: markdownRef.current } : prev));
setEditedMarkdown(null);
toast.success("Document saved! Reindexing in background...");
return true;
} catch (err) {
console.error("Error saving document:", err);
toast.error(err instanceof Error ? err.message : "Failed to save document");
return false;
} finally {
setSaving(false);
}
}, [documentId, electronAPI, isLocalFileMode, localFilePath, resolveLocalVirtualPath, searchSpaceId]);
},
[
documentId,
electronAPI,
isLocalFileMode,
localFilePath,
resolveLocalVirtualPath,
searchSpaceId,
]
);
const isEditableType = editorDoc
? (editorRenderMode === "source_code" ||
@ -594,9 +610,7 @@ export function EditorPanelContent({
}
}}
>
<span
className={`flex items-center gap-1.5 ${downloading ? "opacity-0" : ""}`}
>
<span className={`flex items-center gap-1.5 ${downloading ? "opacity-0" : ""}`}>
<Download className="size-3.5" />
Download .md
</span>
@ -626,7 +640,7 @@ export function EditorPanelContent({
</div>
) : isEditableType ? (
<PlateEditor
key={`${isLocalFileMode ? localFilePath ?? "local-file" : documentId}-${isEditing ? "editing" : "viewing"}`}
key={`${isLocalFileMode ? (localFilePath ?? "local-file") : documentId}-${isEditing ? "editing" : "viewing"}`}
preset="full"
markdown={editorDoc.source_markdown}
onMarkdownChange={handleMarkdownChange}
@ -746,7 +760,8 @@ export function MobileEditorPanel() {
? !!panelState.documentId && !!panelState.searchSpaceId
: !!panelState.localFilePath;
if (isDesktop || !panelState.isOpen || !hasTarget || panelState.kind === "local_file") return null;
if (isDesktop || !panelState.isOpen || !hasTarget || panelState.kind === "local_file")
return null;
return <MobileEditorDrawer />;
}

View file

@ -1,7 +1,6 @@
"use client";
import { createPlatePlugin } from "platejs/react";
import { useEditorReadOnly } from "platejs/react";
import { createPlatePlugin, useEditorReadOnly } from "platejs/react";
import { useEditorSave } from "@/components/editor/editor-save-context";
import { FixedToolbar } from "@/components/ui/fixed-toolbar";

View file

@ -1,8 +1,8 @@
"use client";
import dynamic from "next/dynamic";
import { useEffect, useRef } from "react";
import { useTheme } from "next-themes";
import { useEffect, useRef } from "react";
import { Spinner } from "@/components/ui/spinner";
const MonacoEditor = dynamic(() => import("@monaco-editor/react"), {

View file

@ -72,9 +72,7 @@ export function RightPanelExpandButton() {
const reportOpen = reportState.isOpen && !!reportState.reportId;
const editorOpen =
editorState.isOpen &&
(editorState.kind === "document"
? !!editorState.documentId
: !!editorState.localFilePath);
(editorState.kind === "document" ? !!editorState.documentId : !!editorState.localFilePath);
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
const hasContent = documentsOpen || reportOpen || editorOpen || hitlEditOpen;
@ -116,9 +114,7 @@ export function RightPanel({ documentsPanel }: RightPanelProps) {
const reportOpen = reportState.isOpen && !!reportState.reportId;
const editorOpen =
editorState.isOpen &&
(editorState.kind === "document"
? !!editorState.documentId
: !!editorState.localFilePath);
(editorState.kind === "document" ? !!editorState.documentId : !!editorState.localFilePath);
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
useEffect(() => {

View file

@ -1,11 +1,9 @@
"use client";
import { Folder, FolderPlus, Search, X } from "lucide-react";
import { useAtom } from "jotai";
import { Folder, FolderPlus, Search, X } from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
import { localExpandedFolderKeysAtom } from "@/atoms/documents/folder.atoms";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
DropdownMenu,
DropdownMenuContent,
@ -14,6 +12,8 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { LocalFilesystemBrowser } from "./LocalFilesystemBrowser";

View file

@ -71,7 +71,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useMediaQuery } from "@/hooks/use-media-query";
import { usePlatform, useElectronAPI } from "@/hooks/use-platform";
import { useElectronAPI, usePlatform } from "@/hooks/use-platform";
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { foldersApiService } from "@/lib/apis/folders-api.service";
@ -208,7 +208,8 @@ function AuthenticatedDocumentsSidebarBase({
const [watchedFolderIds, setWatchedFolderIds] = useState<Set<number>>(new Set());
const [folderWatchOpen, setFolderWatchOpen] = useAtom(folderWatchDialogOpenAtom);
const [watchInitialFolder, setWatchInitialFolder] = useAtom(folderWatchInitialFolderAtom);
const isElectron = desktopFeaturesEnabled && typeof window !== "undefined" && !!window.electronAPI;
const isElectron =
desktopFeaturesEnabled && typeof window !== "undefined" && !!window.electronAPI;
useEffect(() => {
if (!electronAPI?.getAgentFilesystemSettings) return;
@ -250,10 +251,13 @@ function AuthenticatedDocumentsSidebarBase({
.filter((rootPath, index, allPaths) => allPaths.indexOf(rootPath) === index)
.slice(0, MAX_LOCAL_FILESYSTEM_ROOTS);
if (nextLocalRootPaths.length === localRootPaths.length) return;
const updated = await electronAPI.setAgentFilesystemSettings({
mode: "desktop_local_folder",
localRootPaths: nextLocalRootPaths,
}, searchSpaceId);
const updated = await electronAPI.setAgentFilesystemSettings(
{
mode: "desktop_local_folder",
localRootPaths: nextLocalRootPaths,
},
searchSpaceId
);
setFilesystemSettings(updated);
},
[electronAPI, localRootPaths, searchSpaceId]
@ -282,10 +286,13 @@ function AuthenticatedDocumentsSidebarBase({
const handleRemoveFilesystemRoot = useCallback(
async (rootPathToRemove: string) => {
if (!electronAPI?.setAgentFilesystemSettings) return;
const updated = await electronAPI.setAgentFilesystemSettings({
mode: "desktop_local_folder",
localRootPaths: localRootPaths.filter((rootPath) => rootPath !== rootPathToRemove),
}, searchSpaceId);
const updated = await electronAPI.setAgentFilesystemSettings(
{
mode: "desktop_local_folder",
localRootPaths: localRootPaths.filter((rootPath) => rootPath !== rootPathToRemove),
},
searchSpaceId
);
setFilesystemSettings(updated);
},
[electronAPI, localRootPaths, searchSpaceId]
@ -293,19 +300,25 @@ function AuthenticatedDocumentsSidebarBase({
const handleClearFilesystemRoots = useCallback(async () => {
if (!electronAPI?.setAgentFilesystemSettings) return;
const updated = await electronAPI.setAgentFilesystemSettings({
mode: "desktop_local_folder",
localRootPaths: [],
}, searchSpaceId);
const updated = await electronAPI.setAgentFilesystemSettings(
{
mode: "desktop_local_folder",
localRootPaths: [],
},
searchSpaceId
);
setFilesystemSettings(updated);
}, [electronAPI, searchSpaceId]);
const handleFilesystemTabChange = useCallback(
async (tab: "cloud" | "local") => {
if (!electronAPI?.setAgentFilesystemSettings) return;
const updated = await electronAPI.setAgentFilesystemSettings({
mode: tab === "cloud" ? "cloud" : "desktop_local_folder",
}, searchSpaceId);
const updated = await electronAPI.setAgentFilesystemSettings(
{
mode: tab === "cloud" ? "cloud" : "desktop_local_folder",
},
searchSpaceId
);
setFilesystemSettings(updated);
},
[electronAPI, searchSpaceId]
@ -552,7 +565,9 @@ function AuthenticatedDocumentsSidebarBase({
if (!electronAPI) return;
const watchedFolders = (await electronAPI.getWatchedFolders()) as WatchedFolderEntry[];
const matched = watchedFolders.find((wf: WatchedFolderEntry) => wf.rootFolderId === folder.id);
const matched = watchedFolders.find(
(wf: WatchedFolderEntry) => wf.rootFolderId === folder.id
);
if (!matched) {
toast.error("This folder is not being watched");
return;
@ -582,7 +597,9 @@ function AuthenticatedDocumentsSidebarBase({
if (!electronAPI) return;
const watchedFolders = (await electronAPI.getWatchedFolders()) as WatchedFolderEntry[];
const matched = watchedFolders.find((wf: WatchedFolderEntry) => wf.rootFolderId === folder.id);
const matched = watchedFolders.find(
(wf: WatchedFolderEntry) => wf.rootFolderId === folder.id
);
if (!matched) {
toast.error("This folder is not being watched");
return;
@ -1015,7 +1032,8 @@ function AuthenticatedDocumentsSidebarBase({
}, [open, onOpenChange, isMobile, setRightPanelCollapsed]);
const showFilesystemTabs = !isMobile && !!electronAPI && !!filesystemSettings;
const currentFilesystemTab = filesystemSettings?.mode === "desktop_local_folder" ? "local" : "cloud";
const currentFilesystemTab =
filesystemSettings?.mode === "desktop_local_folder" ? "local" : "cloud";
const showCloudSkeleton =
currentFilesystemTab === "cloud" &&
(zeroFoldersResult.type !== "complete" || zeroAllDocsResult.type !== "complete");
@ -1331,8 +1349,8 @@ function AuthenticatedDocumentsSidebarBase({
<AlertDialogHeader>
<AlertDialogTitle>Trust this workspace?</AlertDialogTitle>
<AlertDialogDescription>
Local mode can read and edit files inside the folders you select. Continue only if
you trust this workspace and its contents.
Local mode can read and edit files inside the folders you select. Continue only if you
trust this workspace and its contents.
</AlertDialogDescription>
{pendingLocalPath && (
<AlertDialogDescription className="mt-1 whitespace-pre-wrap break-words font-mono text-xs">

View file

@ -141,7 +141,9 @@ export function LocalFilesystemBrowser({
}: LocalFilesystemBrowserProps) {
const electronAPI = useElectronAPI();
const [rootStateMap, setRootStateMap] = useState<Record<string, RootLoadState>>({});
const [internalExpandedFolderKeys, setInternalExpandedFolderKeys] = useState<Set<string>>(new Set());
const [internalExpandedFolderKeys, setInternalExpandedFolderKeys] = useState<Set<string>>(
new Set()
);
const [mountByRootKey, setMountByRootKey] = useState<Map<string, string>>(new Map());
const [mountStatus, setMountStatus] = useState<MountLoadStatus>("idle");
const [mountRefreshInFlight, setMountRefreshInFlight] = useState(false);
@ -188,10 +190,7 @@ export function LocalFilesystemBrowser({
}
for (const { rootKey } of rootsToReload) {
const nonce = reloadNonceByRoot[rootKey] ?? 0;
lastLoadedSignatureByRootRef.current.set(
rootKey,
`${searchSpaceId}:${rootKey}:${nonce}`
);
lastLoadedSignatureByRootRef.current.set(rootKey, `${searchSpaceId}:${rootKey}:${nonce}`);
}
let cancelled = false;
@ -257,35 +256,37 @@ export function LocalFilesystemBrowser({
return;
}
const unsubscribe = electronAPI.onAgentFilesystemTreeDirty((event: {
searchSpaceId: number | null;
reason: "watcher_event" | "safety_poll";
rootPath: string;
changedPath: string | null;
timestamp: number;
}) => {
if ((event.searchSpaceId ?? null) !== (searchSpaceId ?? null)) {
return;
const unsubscribe = electronAPI.onAgentFilesystemTreeDirty(
(event: {
searchSpaceId: number | null;
reason: "watcher_event" | "safety_poll";
rootPath: string;
changedPath: string | null;
timestamp: number;
}) => {
if ((event.searchSpaceId ?? null) !== (searchSpaceId ?? null)) {
return;
}
const eventRootKey = normalizeRootPathForLookup(event.rootPath, isWindowsPlatform);
const knownRootKeys = new Set(
rootPaths.map((rootPath) => normalizeRootPathForLookup(rootPath, isWindowsPlatform))
);
if (!knownRootKeys.has(eventRootKey)) {
setReloadNonceByRoot((prev) => {
const next = { ...prev };
for (const rootKey of knownRootKeys) {
next[rootKey] = (prev[rootKey] ?? 0) + 1;
}
return next;
});
return;
}
setReloadNonceByRoot((prev) => ({
...prev,
[eventRootKey]: (prev[eventRootKey] ?? 0) + 1,
}));
}
const eventRootKey = normalizeRootPathForLookup(event.rootPath, isWindowsPlatform);
const knownRootKeys = new Set(
rootPaths.map((rootPath) => normalizeRootPathForLookup(rootPath, isWindowsPlatform))
);
if (!knownRootKeys.has(eventRootKey)) {
setReloadNonceByRoot((prev) => {
const next = { ...prev };
for (const rootKey of knownRootKeys) {
next[rootKey] = (prev[rootKey] ?? 0) + 1;
}
return next;
});
return;
}
setReloadNonceByRoot((prev) => ({
...prev,
[eventRootKey]: (prev[eventRootKey] ?? 0) + 1,
}));
});
);
void electronAPI.startAgentFilesystemTreeWatch({
searchSpaceId,
rootPaths,
@ -378,22 +379,25 @@ export function LocalFilesystemBrowser({
});
}, [rootPaths, rootStateMap, searchQuery]);
const toggleFolder = useCallback((folderKey: string) => {
const update = (prev: Set<string>) => {
const next = new Set(prev);
if (next.has(folderKey)) {
next.delete(folderKey);
} else {
next.add(folderKey);
const toggleFolder = useCallback(
(folderKey: string) => {
const update = (prev: Set<string>) => {
const next = new Set(prev);
if (next.has(folderKey)) {
next.delete(folderKey);
} else {
next.add(folderKey);
}
return next;
};
if (onExpandedFolderKeysChange) {
onExpandedFolderKeysChange(update(effectiveExpandedFolderKeys));
return;
}
return next;
};
if (onExpandedFolderKeysChange) {
onExpandedFolderKeysChange(update(effectiveExpandedFolderKeys));
return;
}
setInternalExpandedFolderKeys(update);
}, [effectiveExpandedFolderKeys, onExpandedFolderKeysChange]);
setInternalExpandedFolderKeys(update);
},
[effectiveExpandedFolderKeys, onExpandedFolderKeysChange]
);
const renderFolder = useCallback(
(folder: LocalFolderNode, depth: number, mount: string) => {
@ -436,9 +440,7 @@ export function LocalFilesystemBrowser({
: undefined
}
className={`flex h-8 w-full items-center gap-1.5 rounded-md px-2 text-left text-sm transition-colors ${
isOpenable
? "hover:bg-muted/60"
: "cursor-not-allowed opacity-60"
isOpenable ? "hover:bg-muted/60" : "cursor-not-allowed opacity-60"
}`}
style={{ paddingInlineStart: `${(depth + 1) * 12 + 22}px` }}
title={
@ -528,7 +530,10 @@ export function LocalFilesystemBrowser({
}
if (state.error) {
return (
<div key={rootPath} className="rounded-md border border-destructive/20 bg-destructive/5 p-3">
<div
key={rootPath}
className="rounded-md border border-destructive/20 bg-destructive/5 p-3"
>
<p className="text-sm font-medium text-destructive">Failed to load local folder</p>
<p className="mt-1 text-xs text-muted-foreground">{state.error}</p>
</div>

View file

@ -308,9 +308,7 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
}
}}
>
<span
className={`flex items-center gap-1.5 ${downloading ? "opacity-0" : ""}`}
>
<span className={`flex items-center gap-1.5 ${downloading ? "opacity-0" : ""}`}>
<Download className="size-3.5" />
Download .md
</span>

View file

@ -8,9 +8,9 @@ import {
ChevronLeft,
ChevronRight,
ChevronUp,
Pencil,
ImageIcon,
Layers,
Pencil,
Plus,
ScanEye,
Search,
@ -741,9 +741,7 @@ export function ModelSelector({
<div
className={cn(
"shrink-0 border-border/50 flex relative",
isMobile
? "flex-row items-center border-b border-border/40"
: "flex-col w-10 border-r"
isMobile ? "flex-row items-center border-b border-border/40" : "flex-col w-10 border-r"
)}
>
{!isMobile && (
@ -769,9 +767,7 @@ export function ModelSelector({
<div
className={cn(
"absolute left-0 top-0 bottom-0 z-10 w-5 flex items-center justify-center transition-all duration-200 ease-out pointer-events-none",
sidebarScrollPos === "top"
? "opacity-0 -translate-x-1"
: "opacity-100 translate-x-0"
sidebarScrollPos === "top" ? "opacity-0 -translate-x-1" : "opacity-100 translate-x-0"
)}
>
<ChevronLeft className="size-3 text-muted-foreground" />

View file

@ -398,7 +398,8 @@ export function ReportPanelContent({
</Button>
);
const editingActions = showReportEditingTier &&
const editingActions =
showReportEditingTier &&
!isReadOnly &&
(isEditing ? (
<>

View file

@ -1,15 +1,7 @@
"use client";
import { useAtomValue } from "jotai";
import {
AlertCircle,
Dot,
FileText,
Info,
Pencil,
RefreshCw,
Trash2,
} from "lucide-react";
import { AlertCircle, Dot, FileText, Info, Pencil, RefreshCw, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
import { deleteNewLLMConfigMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";

View file

@ -5,10 +5,8 @@ import { useAtomValue } from "jotai";
import {
Bot,
ChevronRight,
ScanEye,
Pencil,
FileText,
Earth,
FileText,
Image,
Logs,
type LucideIcon,
@ -16,11 +14,13 @@ import {
MessageSquare,
Mic,
MoreHorizontal,
Unplug,
Pencil,
ScanEye,
Settings,
Shield,
SlidersHorizontal,
Trash2,
Unplug,
Users,
Video,
} from "lucide-react";
@ -462,9 +462,19 @@ function RolesContent({
return (
<div key={role.id} className="rounded-lg border border-border/60 overflow-hidden">
{/* biome-ignore lint/a11y/useSemanticElements: row contains nested interactive elements (DropdownMenu); using a <button> would produce invalid nested-button markup */}
<div
className="flex items-center gap-4 p-4 transition-colors hover:bg-muted/30 cursor-pointer"
role="button"
tabIndex={0}
aria-expanded={isExpanded}
className="flex items-center gap-4 p-4 transition-colors hover:bg-muted/30 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setExpandedRoleId(isExpanded ? null : role.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setExpandedRoleId(isExpanded ? null : role.id);
}
}}
>
<div className="flex-1 min-w-0 text-left">
<div className="flex items-center gap-2">
@ -682,9 +692,19 @@ function PermissionsEditor({
return (
<div key={category} className="rounded-lg border border-border/60 overflow-hidden">
{/* biome-ignore lint/a11y/useSemanticElements: row contains a nested interactive Checkbox; using a <button> would produce invalid nested-button markup */}
<div
className="flex items-center justify-between px-3 py-2.5 hover:bg-muted/40 transition-colors cursor-pointer"
role="button"
tabIndex={0}
aria-expanded={isExpanded}
className="flex items-center justify-between px-3 py-2.5 hover:bg-muted/40 transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => toggleCategoryExpanded(category)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleCategoryExpanded(category);
}
}}
>
<div className="flex-1 flex items-center gap-2.5">
<IconComponent className="h-4 w-4 text-muted-foreground shrink-0" />

View file

@ -1,7 +1,16 @@
"use client";
import { useAtom } from "jotai";
import { Brain, CircleUser, Globe, Keyboard, KeyRound, Monitor, ReceiptText, Sparkles } from "lucide-react";
import {
Brain,
CircleUser,
Globe,
Keyboard,
KeyRound,
Monitor,
ReceiptText,
Sparkles,
} from "lucide-react";
import dynamic from "next/dynamic";
import { useTranslations } from "next-intl";
import { useMemo } from "react";
@ -53,9 +62,9 @@ const DesktopContent = dynamic(
);
const DesktopShortcutsContent = dynamic(
() =>
import("@/app/dashboard/[search_space_id]/user-settings/components/DesktopShortcutsContent").then(
(m) => ({ default: m.DesktopShortcutsContent })
),
import(
"@/app/dashboard/[search_space_id]/user-settings/components/DesktopShortcutsContent"
).then((m) => ({ default: m.DesktopShortcutsContent })),
{ ssr: false }
);
const MemoryContent = dynamic(

View file

@ -118,7 +118,9 @@ function GenericApprovalCard({
setProcessing();
onDecision({ type: "approve" });
connectorsApiService.trustMCPTool(mcpConnectorId, toolName).catch(() => {
toast.error("Failed to save 'Always Allow' preference. The tool will still require approval next time.");
toast.error(
"Failed to save 'Always Allow' preference. The tool will still require approval next time."
);
});
}, [phase, setProcessing, onDecision, isMCPTool, mcpConnectorId, toolName]);

View file

@ -2,7 +2,14 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { ClockIcon, CornerDownLeftIcon, GlobeIcon, MapPinIcon, Pencil, UsersIcon } from "lucide-react";
import {
ClockIcon,
CornerDownLeftIcon,
GlobeIcon,
MapPinIcon,
Pencil,
UsersIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";

View file

@ -1,8 +1,8 @@
import {
BookOpen,
Brain,
FileUser,
FileText,
FileUser,
Film,
Globe,
ImageIcon,

View file

@ -179,9 +179,7 @@ interface ElectronAPI {
// Agent filesystem mode
getAgentFilesystemSettings: (searchSpaceId?: number | null) => Promise<AgentFilesystemSettings>;
getAgentFilesystemMounts: (searchSpaceId?: number | null) => Promise<AgentFilesystemMount[]>;
listAgentFilesystemFiles: (
options: AgentFilesystemListOptions
) => Promise<FolderFileEntry[]>;
listAgentFilesystemFiles: (options: AgentFilesystemListOptions) => Promise<FolderFileEntry[]>;
startAgentFilesystemTreeWatch: (
options: AgentFilesystemTreeWatchOptions
) => Promise<{ ok: true }>;
@ -189,10 +187,13 @@ interface ElectronAPI {
onAgentFilesystemTreeDirty: (
callback: (data: AgentFilesystemTreeDirtyEvent) => void
) => () => void;
setAgentFilesystemSettings: (settings: {
mode?: AgentFilesystemMode;
localRootPaths?: string[] | null;
}, searchSpaceId?: number | null) => Promise<AgentFilesystemSettings>;
setAgentFilesystemSettings: (
settings: {
mode?: AgentFilesystemMode;
localRootPaths?: string[] | null;
},
searchSpaceId?: number | null
) => Promise<AgentFilesystemSettings>;
pickAgentFilesystemRoot: () => Promise<string | null>;
}