mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-08 22:22:17 +02:00
feat: docs and ui tweaks
This commit is contained in:
parent
64f2b4a6eb
commit
271a21aee6
103 changed files with 2161 additions and 2625 deletions
|
|
@ -649,14 +649,31 @@ 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]
|
||||
logger.info(
|
||||
"HTTP MCP server '%s' (connector %d): %d/%d tools after allowlist filter",
|
||||
url,
|
||||
connector_id,
|
||||
len(tool_definitions),
|
||||
total_discovered,
|
||||
)
|
||||
filtered = [td for td in tool_definitions if td["name"] in allowed_set]
|
||||
if not filtered and total_discovered:
|
||||
# The server renamed its tools out from under our allowlist
|
||||
# (e.g. Notion's "notion-" prefix rename) — a fully-dead
|
||||
# allowlist would silently disable the connector. Load
|
||||
# everything instead: renamed tools won't match
|
||||
# ``readonly_tools`` either, so every tool stays HITL-gated.
|
||||
logger.warning(
|
||||
"HTTP MCP server '%s' (connector %d): allowlist matched 0/%d "
|
||||
"advertised tools — server likely renamed its tools. "
|
||||
"Loading all tools (HITL-gated). Update the registry allowlist: %s",
|
||||
url,
|
||||
connector_id,
|
||||
total_discovered,
|
||||
sorted(td["name"] for td in tool_definitions),
|
||||
)
|
||||
else:
|
||||
tool_definitions = filtered
|
||||
logger.info(
|
||||
"HTTP MCP server '%s' (connector %d): %d/%d tools after allowlist filter",
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ from langchain_core.tools import BaseTool
|
|||
from app.agents.chat.multi_agent_chat.shared.filesystem_selection import FilesystemMode
|
||||
from app.agents.chat.multi_agent_chat.shared.permissions import Rule, Ruleset
|
||||
from app.agents.chat.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
|
||||
from app.agents.chat.multi_agent_chat.subagents.shared.subagent_builder import (
|
||||
append_today_utc,
|
||||
)
|
||||
|
||||
from .middleware_stack import build_kb_middleware
|
||||
from .prompts import load_description, load_readonly_system_prompt, load_system_prompt
|
||||
|
|
@ -57,7 +60,7 @@ def build_subagent(
|
|||
{
|
||||
"name": NAME,
|
||||
"description": load_description(),
|
||||
"system_prompt": load_system_prompt(filesystem_mode),
|
||||
"system_prompt": append_today_utc(load_system_prompt(filesystem_mode)),
|
||||
"model": llm,
|
||||
"tools": [_build_search_knowledge_base_tool(dependencies)],
|
||||
"middleware": build_kb_middleware(
|
||||
|
|
@ -86,7 +89,9 @@ def build_readonly_subagent(
|
|||
{
|
||||
"name": READONLY_NAME,
|
||||
"description": "Read-only knowledge_base specialist (invoked via ask_knowledge_base).",
|
||||
"system_prompt": load_readonly_system_prompt(filesystem_mode),
|
||||
"system_prompt": append_today_utc(
|
||||
load_readonly_system_prompt(filesystem_mode)
|
||||
),
|
||||
"model": llm,
|
||||
"tools": [_build_search_knowledge_base_tool(dependencies)],
|
||||
"middleware": build_kb_middleware(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from __future__ import annotations
|
|||
import logging
|
||||
import re
|
||||
import time as _perf_time
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, cast
|
||||
|
||||
from deepagents import SubAgent
|
||||
|
|
@ -60,6 +61,19 @@ def _resolve_includes(prompt: str, *, subagent_name: str) -> str:
|
|||
return _INCLUDE_DIRECTIVE_RE.sub(_replace, prompt)
|
||||
|
||||
|
||||
def append_today_utc(prompt: str) -> str:
|
||||
"""Append the current UTC date so subagents share the main agent's clock.
|
||||
|
||||
The main agent injects ``Today (UTC): ...`` into its identity prompt, but
|
||||
delegated subagents (calendar/gmail/drive, date-filtered searches, etc.)
|
||||
never saw it and would guess the date. Appended at build time; the compiled
|
||||
graph is cache-keyed on the main prompt hash (which carries the same date),
|
||||
so a day rollover rebuilds both together and they stay consistent.
|
||||
"""
|
||||
today = datetime.now(UTC).date().isoformat()
|
||||
return f"{prompt}\n\nToday (UTC): {today}\n"
|
||||
|
||||
|
||||
def _user_allowlist_for(
|
||||
dependencies: dict[str, Any], subagent_name: str
|
||||
) -> Ruleset | None:
|
||||
|
|
@ -115,6 +129,7 @@ def pack_subagent(
|
|||
|
||||
_t0 = _perf_time.perf_counter()
|
||||
system_prompt = _resolve_includes(system_prompt, subagent_name=name)
|
||||
system_prompt = append_today_utc(system_prompt)
|
||||
_t_resolve = _perf_time.perf_counter() - _t0
|
||||
|
||||
flags = dependencies["flags"]
|
||||
|
|
|
|||
|
|
@ -28,7 +28,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
from app.auth.context import AuthContext
|
||||
from app.capabilities.core.access.rate_limit import enforce_capability_rate_limit
|
||||
from app.capabilities.core.billing import charge_capability, gate_capability
|
||||
from app.capabilities.core.billing import (
|
||||
charge_capability,
|
||||
gate_capability,
|
||||
pricing_meters,
|
||||
)
|
||||
from app.capabilities.core.events import run_event_bus
|
||||
from app.capabilities.core.progress import progress_scope
|
||||
from app.capabilities.core.runs import (
|
||||
|
|
@ -55,13 +59,22 @@ _SSE_HEADERS = {
|
|||
}
|
||||
|
||||
|
||||
class PricingMeter(BaseModel):
|
||||
"""One live per-item rate a verb charges on, e.g. 3500 micro-USD per place."""
|
||||
|
||||
unit: str
|
||||
micros_per_unit: int
|
||||
|
||||
|
||||
class CapabilitySummary(BaseModel):
|
||||
"""A verb's identity + input/output JSON schemas, for the playground UI."""
|
||||
"""A verb's identity + input/output JSON schemas + pricing, for the playground UI."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
input_schema: dict
|
||||
output_schema: dict
|
||||
# Empty list = free (billing disabled or an unmetered verb).
|
||||
pricing: list[PricingMeter] = []
|
||||
|
||||
|
||||
class RunSummary(BaseModel):
|
||||
|
|
@ -125,12 +138,17 @@ def _register_capabilities_list(
|
|||
) -> None:
|
||||
"""Register the ``GET`` that lists verbs + their input schemas for the UI."""
|
||||
|
||||
summaries = [
|
||||
CapabilitySummary(
|
||||
name=capability.name,
|
||||
description=capability.description,
|
||||
input_schema=capability.input_schema.model_json_schema(),
|
||||
output_schema=capability.output_schema.model_json_schema(),
|
||||
# Schemas are static; pricing is attached per request because rates are
|
||||
# read live from config (env retune + restart, no rebuild).
|
||||
base_summaries = [
|
||||
(
|
||||
CapabilitySummary(
|
||||
name=capability.name,
|
||||
description=capability.description,
|
||||
input_schema=capability.input_schema.model_json_schema(),
|
||||
output_schema=capability.output_schema.model_json_schema(),
|
||||
),
|
||||
capability.billing_unit,
|
||||
)
|
||||
for capability in capabilities
|
||||
]
|
||||
|
|
@ -141,7 +159,12 @@ def _register_capabilities_list(
|
|||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> list[CapabilitySummary]:
|
||||
await check_workspace_access(session, auth, workspace_id)
|
||||
return summaries
|
||||
return [
|
||||
summary.model_copy(
|
||||
update={"pricing": [PricingMeter(**m) for m in pricing_meters(unit)]}
|
||||
)
|
||||
for summary, unit in base_summaries
|
||||
]
|
||||
|
||||
router.add_api_route(
|
||||
"/workspaces/{workspace_id}/scrapers/capabilities",
|
||||
|
|
|
|||
|
|
@ -43,6 +43,53 @@ def _platform_rate(unit: BillingUnit) -> int:
|
|||
return int(getattr(config, _PLATFORM_RATE_KEYS[unit]))
|
||||
|
||||
|
||||
# Display noun for each platform meter, e.g. "$3.50 / 1k places".
|
||||
_UNIT_NOUNS: dict[BillingUnit, str] = {
|
||||
BillingUnit.REDDIT_ITEM: "item",
|
||||
BillingUnit.GOOGLE_SEARCH_SERP: "SERP",
|
||||
BillingUnit.GOOGLE_MAPS_PLACE: "place",
|
||||
BillingUnit.GOOGLE_MAPS_REVIEW: "review",
|
||||
BillingUnit.YOUTUBE_VIDEO: "video",
|
||||
BillingUnit.YOUTUBE_COMMENT: "comment",
|
||||
}
|
||||
|
||||
|
||||
def pricing_meters(unit: BillingUnit | None) -> list[dict]:
|
||||
"""The live per-item rates a verb charges, for UI display. Empty = free.
|
||||
|
||||
Mirrors the gate/charge logic exactly: meters whose billing flag is off are
|
||||
omitted, so a self-hosted install with billing disabled reads as free.
|
||||
"""
|
||||
if unit is None:
|
||||
return []
|
||||
if unit is BillingUnit.WEB_CRAWL:
|
||||
meters = []
|
||||
if WebCrawlCreditService.billing_enabled():
|
||||
meters.append(
|
||||
{"unit": "page", "micros_per_unit": config.WEB_CRAWL_MICROS_PER_SUCCESS}
|
||||
)
|
||||
if WebCrawlCreditService.captcha_billing_enabled() and captcha_enabled():
|
||||
meters.append(
|
||||
{
|
||||
"unit": "captcha solve",
|
||||
"micros_per_unit": config.WEB_CRAWL_CAPTCHA_MICROS_PER_SOLVE,
|
||||
}
|
||||
)
|
||||
return meters
|
||||
if not config.PLATFORM_SCRAPE_BILLING_ENABLED:
|
||||
return []
|
||||
meters = [{"unit": _UNIT_NOUNS[unit], "micros_per_unit": _platform_rate(unit)}]
|
||||
if unit is BillingUnit.GOOGLE_MAPS_PLACE:
|
||||
# Dual-metered: attached reviews bill on their own meter.
|
||||
meters.append(
|
||||
{
|
||||
"unit": _UNIT_NOUNS[BillingUnit.GOOGLE_MAPS_REVIEW],
|
||||
"micros_per_unit": _platform_rate(BillingUnit.GOOGLE_MAPS_REVIEW),
|
||||
}
|
||||
)
|
||||
return meters
|
||||
|
||||
|
||||
async def gate_capability(
|
||||
payload: BillableInput, unit: BillingUnit | None, ctx: CapabilityContext
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -153,13 +153,26 @@ MCP_SERVICES: dict[str, MCPServiceConfig] = {
|
|||
"mpim:history",
|
||||
"im:history",
|
||||
],
|
||||
# Both prefixed and unprefixed variants: sources disagree on which
|
||||
# names mcp.slack.com currently advertises. Unmatched entries are
|
||||
# ignored at discovery time.
|
||||
allowed_tools=[
|
||||
"slack_search_channels",
|
||||
"slack_read_channel",
|
||||
"slack_read_thread",
|
||||
"search_channels",
|
||||
"read_channel",
|
||||
"read_thread",
|
||||
],
|
||||
readonly_tools=frozenset(
|
||||
{"slack_search_channels", "slack_read_channel", "slack_read_thread"}
|
||||
{
|
||||
"slack_search_channels",
|
||||
"slack_read_channel",
|
||||
"slack_read_thread",
|
||||
"search_channels",
|
||||
"read_channel",
|
||||
"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
|
||||
|
|
@ -192,13 +205,22 @@ MCP_SERVICES: dict[str, MCPServiceConfig] = {
|
|||
# DCR (RFC 7591): Notion issues its own client credentials. It expires
|
||||
# DCR registrations, but refresh reuses the original persisted
|
||||
# ``mcp_oauth.client_id`` (see _refresh_connector_token).
|
||||
# Notion renamed its MCP tools with a "notion-" prefix (late 2025).
|
||||
# Unprefixed names kept for servers still advertising the old names —
|
||||
# allowlist entries the server doesn't advertise are simply ignored.
|
||||
allowed_tools=[
|
||||
"notion-search",
|
||||
"notion-fetch",
|
||||
"notion-create-pages",
|
||||
"notion-update-page",
|
||||
"search",
|
||||
"fetch",
|
||||
"create-pages",
|
||||
"update-page",
|
||||
],
|
||||
readonly_tools=frozenset({"search", "fetch"}),
|
||||
readonly_tools=frozenset(
|
||||
{"notion-search", "notion-fetch", "search", "fetch"}
|
||||
),
|
||||
account_metadata_keys=["workspace_name"],
|
||||
),
|
||||
"confluence": MCPServiceConfig(
|
||||
|
|
|
|||
|
|
@ -30,6 +30,12 @@ DEPRECATED_CONNECTOR_TYPES: frozenset[str] = frozenset(
|
|||
"SEARXNG_API",
|
||||
"LINKUP_API",
|
||||
"BAIDU_SEARCH_API",
|
||||
# Legacy content crawlers/search superseded by the file Import menu and
|
||||
# hosted MCP tooling. Created via the generic connector /add route, which
|
||||
# is the single choke point enforcing this deprecation.
|
||||
"YOUTUBE_CONNECTOR",
|
||||
"WEBCRAWLER_CONNECTOR",
|
||||
"ELASTICSEARCH_CONNECTOR",
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue