mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-16 21:05:20 +02:00
chore: linting
Some checks failed
Obsidian Plugin Lint / lint (push) Has been cancelled
Some checks failed
Obsidian Plugin Lint / lint (push) Has been cancelled
This commit is contained in:
parent
f607636ba6
commit
8d50f90060
74 changed files with 1135 additions and 693 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ from typing import Any
|
|||
|
||||
|
||||
class ConnectorError(Exception):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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=" ",
|
||||
|
|
|
|||
|
|
@ -45,4 +45,3 @@ def test_contract_enforcement_local_only():
|
|||
|
||||
result.filesystem_mode = "cloud"
|
||||
assert not _contract_enforcement_active(result)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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'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're a member of the teams you want to interact
|
||||
with.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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"), {
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -398,7 +398,8 @@ export function ReportPanelContent({
|
|||
</Button>
|
||||
);
|
||||
|
||||
const editingActions = showReportEditingTier &&
|
||||
const editingActions =
|
||||
showReportEditingTier &&
|
||||
!isReadOnly &&
|
||||
(isEditing ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import {
|
||||
BookOpen,
|
||||
Brain,
|
||||
FileUser,
|
||||
FileText,
|
||||
FileUser,
|
||||
Film,
|
||||
Globe,
|
||||
ImageIcon,
|
||||
|
|
|
|||
15
surfsense_web/types/window.d.ts
vendored
15
surfsense_web/types/window.d.ts
vendored
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue