From 94e834134f7269260095b1c7d1763bcf4fbd582b Mon Sep 17 00:00:00 2001
From: "DESKTOP-RTLN3BA\\$punk"
Date: Thu, 28 May 2026 19:21:29 -0700
Subject: [PATCH 1/6] chore: linting
---
.../versions/144_add_automation_tables.py | 4 +-
.../main_agent/tools/automation/prompt.py | 1 -
.../task_tool.py | 4 +-
.../builtins/deliverables/tools/podcast.py | 4 +-
.../deliverables/tools/video_presentation.py | 7 +-
.../app/agents/new_chat/tools/podcast.py | 8 +-
.../new_chat/tools/video_presentation.py | 4 +-
.../app/automations/actions/__init__.py | 2 +-
.../actions/agent_task/__init__.py | 2 +-
.../automations/actions/agent_task/invoke.py | 1 -
.../app/automations/persistence/models/run.py | 2 +-
.../app/automations/runtime/executor.py | 11 +-
.../app/automations/runtime/step.py | 21 ++-
.../schemas/definition/execution.py | 4 +-
.../schemas/definition/plan_step.py | 4 +-
.../schemas/definition/trigger_spec.py | 4 +-
.../app/automations/services/automation.py | 42 +++--
.../app/automations/services/run.py | 12 +-
.../app/automations/services/trigger.py | 14 +-
.../app/automations/tasks/execute_run.py | 2 +-
.../app/automations/tasks/schedule_tick.py | 4 +-
.../app/automations/triggers/__init__.py | 2 +-
.../automations/triggers/schedule/__init__.py | 2 +-
.../app/automations/triggers/schedule/cron.py | 6 +-
.../automations/triggers/schedule/params.py | 4 +-
surfsense_backend/app/db.py | 1 -
surfsense_backend/app/routes/__init__.py | 3 +-
.../streaming/context/deepagents_todos.py | 4 +-
.../chat/streaming/flows/new_chat/auto_pin.py | 8 +-
.../streaming/flows/new_chat/input_state.py | 4 +-
.../flows/new_chat/llm_capability.py | 4 +-
.../streaming/flows/new_chat/orchestrator.py | 27 ++--
.../flows/new_chat/runtime_context.py | 4 +-
.../streaming/flows/new_chat/title_gen.py | 8 +-
.../flows/resume_chat/orchestrator.py | 37 ++---
.../flows/resume_chat/resume_routing.py | 4 +-
.../flows/shared/assistant_finalize.py | 4 +-
.../streaming/flows/shared/finally_cleanup.py | 4 +-
.../streaming/flows/shared/premium_quota.py | 10 +-
.../tasks/chat/streaming/flows/shared/span.py | 3 +-
.../streaming/flows/shared/terminal_error.py | 3 +-
.../actions/agent_task/test_finalize.py | 6 +-
.../unit/automations/runtime/test_retries.py | 4 +-
.../schemas/definition/test_envelope.py | 4 +-
.../templating/test_environment.py | 4 +-
.../unit/automations/test_definition_types.py | 2 +-
.../tests/unit/automations/test_stores.py | 8 +-
.../triggers/schedule/test_cron.py | 8 +-
.../test_parallel_refactor_parity.py | 20 ++-
surfsense_web/app/(home)/free/page.tsx | 10 +-
surfsense_web/app/(home)/privacy/page.tsx | 98 ++++++------
surfsense_web/app/api/zero/query/route.ts | 2 +-
.../edit/automation-edit-content.tsx | 5 +-
.../edit/components/automation-edit-form.tsx | 5 +-
.../new-chat/[[...chat_id]]/page.tsx | 11 +-
.../[search_space_id]/team/team-content.tsx | 2 +-
.../atoms/members/members-query.atoms.ts | 8 +-
.../assistant-ui/assistant-message.tsx | 2 +-
.../components/obsidian-connect-form.tsx | 3 +-
.../components/circleback-config.tsx | 2 +-
.../views/connector-edit-view.tsx | 1 +
.../views/connector-accounts-list-view.tsx | 1 +
.../assistant-ui/inline-mention-editor.tsx | 20 ++-
.../components/assistant-ui/thread.tsx | 89 ++++++-----
.../components/assistant-ui/user-message.tsx | 8 +-
.../components/editor-panel/editor-panel.tsx | 1 +
.../components/free-chat/anonymous-chat.tsx | 26 ++--
.../layout/ui/icon-rail/SearchSpaceAvatar.tsx | 6 +-
.../layout/ui/sidebar/DocumentsSidebar.tsx | 2 +-
.../layout/ui/tabs/DocumentTabContent.tsx | 1 +
.../new-chat/composer-suggestion-popup.tsx | 5 +-
.../new-chat/document-mention-picker.tsx | 144 ++++++++++--------
.../components/new-chat/prompt-picker.tsx | 4 +-
.../settings/general-settings-manager.tsx | 2 +-
.../settings/prompt-config-manager.tsx | 8 +-
.../components/tool-ui/generate-podcast.tsx | 9 +-
.../generate-video-presentation.tsx | 2 +-
.../hooks/use-search-source-connectors.ts | 12 +-
surfsense_web/lib/auth-utils.ts | 1 +
surfsense_web/lib/posthog/events.ts | 2 +-
80 files changed, 443 insertions(+), 404 deletions(-)
diff --git a/surfsense_backend/alembic/versions/144_add_automation_tables.py b/surfsense_backend/alembic/versions/144_add_automation_tables.py
index 8d836095d..39f927417 100644
--- a/surfsense_backend/alembic/versions/144_add_automation_tables.py
+++ b/surfsense_backend/alembic/versions/144_add_automation_tables.py
@@ -98,9 +98,7 @@ def upgrade() -> None:
op.execute(
"CREATE INDEX ix_automation_triggers_automation_id ON automation_triggers(automation_id);"
)
- op.execute(
- "CREATE INDEX ix_automation_triggers_type ON automation_triggers(type);"
- )
+ op.execute("CREATE INDEX ix_automation_triggers_type ON automation_triggers(type);")
op.execute(
"CREATE INDEX ix_automation_triggers_enabled ON automation_triggers(enabled);"
)
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/automation/prompt.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/automation/prompt.py
index 45870e768..09854aa2e 100644
--- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/automation/prompt.py
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/automation/prompt.py
@@ -28,7 +28,6 @@ from __future__ import annotations
from datetime import UTC, datetime
-
_HEADER = """\
You are the SurfSense automation drafter. Convert the user intent below
into a SINGLE JSON object matching the AutomationCreate schema. Output
diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/task_tool.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/task_tool.py
index 91a0be506..eaed9a55f 100644
--- a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/task_tool.py
+++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/task_tool.py
@@ -404,9 +404,7 @@ def build_task_tool_with_parent_config(
continue
messages = payload.get("messages") or []
last_text = _safe_message_text(messages[-1]).rstrip() if messages else ""
- message_blocks.append(
- f"[task {task_index}] {last_text or ''}"
- )
+ message_blocks.append(f"[task {task_index}] {last_text or ''}")
try:
child_trace = _build_tool_trace(messages)
except Exception:
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/podcast.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/podcast.py
index 84617d38b..298257799 100644
--- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/podcast.py
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/podcast.py
@@ -117,9 +117,7 @@ def create_generate_podcast_tool(
"podcast_id": podcast_id,
"title": podcast_title,
"file_location": file_location,
- "message": (
- "Podcast generated and saved to your podcast panel."
- ),
+ "message": ("Podcast generated and saved to your podcast panel."),
}
return with_receipt(
payload=payload,
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/video_presentation.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/video_presentation.py
index 8c52293de..5407c8834 100644
--- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/video_presentation.py
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/video_presentation.py
@@ -126,8 +126,7 @@ def create_generate_video_presentation_tool(
elapsed,
)
err = (
- "Background worker reported FAILED status for this "
- "video presentation."
+ "Background worker reported FAILED status for this video presentation."
)
payload = {
"status": VideoPresentationStatus.FAILED.value,
@@ -151,9 +150,7 @@ def create_generate_video_presentation_tool(
except Exception as e:
error_message = str(e)
- logger.exception(
- "[generate_video_presentation] Error: %s", error_message
- )
+ logger.exception("[generate_video_presentation] Error: %s", error_message)
payload = {
"status": VideoPresentationStatus.FAILED.value,
"error": error_message,
diff --git a/surfsense_backend/app/agents/new_chat/tools/podcast.py b/surfsense_backend/app/agents/new_chat/tools/podcast.py
index 36aecfe49..83ac98768 100644
--- a/surfsense_backend/app/agents/new_chat/tools/podcast.py
+++ b/surfsense_backend/app/agents/new_chat/tools/podcast.py
@@ -131,9 +131,7 @@ def create_generate_podcast_tool(
"podcast_id": podcast_id,
"title": podcast_title,
"file_location": file_location,
- "message": (
- "Podcast generated and saved to your podcast panel."
- ),
+ "message": ("Podcast generated and saved to your podcast panel."),
}
# Only other terminal state is FAILED.
@@ -146,9 +144,7 @@ def create_generate_podcast_tool(
"status": PodcastStatus.FAILED.value,
"podcast_id": podcast_id,
"title": podcast_title,
- "error": (
- "Background worker reported FAILED status for this podcast."
- ),
+ "error": ("Background worker reported FAILED status for this podcast."),
}
except Exception as e:
diff --git a/surfsense_backend/app/agents/new_chat/tools/video_presentation.py b/surfsense_backend/app/agents/new_chat/tools/video_presentation.py
index 4bf13b28e..34f5183ca 100644
--- a/surfsense_backend/app/agents/new_chat/tools/video_presentation.py
+++ b/surfsense_backend/app/agents/new_chat/tools/video_presentation.py
@@ -127,9 +127,7 @@ def create_generate_video_presentation_tool(
except Exception as e:
error_message = str(e)
- logger.exception(
- "[generate_video_presentation] Error: %s", error_message
- )
+ logger.exception("[generate_video_presentation] Error: %s", error_message)
return {
"status": VideoPresentationStatus.FAILED.value,
"error": error_message,
diff --git a/surfsense_backend/app/automations/actions/__init__.py b/surfsense_backend/app/automations/actions/__init__.py
index 9ef091cb3..72669532f 100644
--- a/surfsense_backend/app/automations/actions/__init__.py
+++ b/surfsense_backend/app/automations/actions/__init__.py
@@ -21,4 +21,4 @@ __all__ = [
]
# Built-in actions self-register at import time.
-from . import agent_task # noqa: E402, F401
+from . import agent_task # noqa: F401
diff --git a/surfsense_backend/app/automations/actions/agent_task/__init__.py b/surfsense_backend/app/automations/actions/agent_task/__init__.py
index 308812211..3a42a2815 100644
--- a/surfsense_backend/app/automations/actions/agent_task/__init__.py
+++ b/surfsense_backend/app/automations/actions/agent_task/__init__.py
@@ -12,4 +12,4 @@ from .params import AgentTaskActionParams
__all__ = ["AgentTaskActionParams", "build_handler"]
# Side-effect: register on the actions store.
-from . import definition # noqa: E402, F401
+from . import definition # noqa: F401
diff --git a/surfsense_backend/app/automations/actions/agent_task/invoke.py b/surfsense_backend/app/automations/actions/agent_task/invoke.py
index a37e9beed..6cc92b232 100644
--- a/surfsense_backend/app/automations/actions/agent_task/invoke.py
+++ b/surfsense_backend/app/automations/actions/agent_task/invoke.py
@@ -13,7 +13,6 @@ from app.agents.multi_agent_chat import create_multi_agent_chat_deep_agent
from app.db import ChatVisibility, async_session_maker
from ..types import ActionContext
-
from .auto_decide import build_auto_decisions
from .dependencies import build_dependencies
from .finalize import extract_final_assistant_message
diff --git a/surfsense_backend/app/automations/persistence/models/run.py b/surfsense_backend/app/automations/persistence/models/run.py
index 262e4c2bf..471b2df77 100644
--- a/surfsense_backend/app/automations/persistence/models/run.py
+++ b/surfsense_backend/app/automations/persistence/models/run.py
@@ -50,7 +50,7 @@ class AutomationRun(BaseModel, TimestampMixin):
definition_snapshot = Column(JSONB, nullable=False)
# merged & validated inputs the run was dispatched with
- # (trigger.static_inputs ∪ producer runtime data, static wins on collision)
+ # (trigger.static_inputs union producer runtime data, static wins on collision)
inputs = Column(JSONB, nullable=False, server_default="{}")
# one entry per executed step; agent_task entries carry their own
# `agent_session_id` inside their entry
diff --git a/surfsense_backend/app/automations/runtime/executor.py b/surfsense_backend/app/automations/runtime/executor.py
index b8a377e5b..6a33ab314 100644
--- a/surfsense_backend/app/automations/runtime/executor.py
+++ b/surfsense_backend/app/automations/runtime/executor.py
@@ -6,9 +6,9 @@ from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
+from app.automations.actions.types import ActionContext
from app.automations.persistence.enums.run_status import RunStatus
from app.automations.persistence.models.run import AutomationRun
-from app.automations.actions.types import ActionContext
from app.automations.schemas.definition.envelope import AutomationDefinition
from app.automations.schemas.definition.plan_step import PlanStep
from app.automations.templating import build_run_context
@@ -32,7 +32,10 @@ async def execute_run(session: AsyncSession, run_id: int) -> None:
await repository.mark_failed(
session,
run,
- {"message": f"definition_snapshot invalid: {exc}", "type": type(exc).__name__},
+ {
+ "message": f"definition_snapshot invalid: {exc}",
+ "type": type(exc).__name__,
+ },
)
await session.commit()
return
@@ -92,7 +95,9 @@ async def _run_on_failure(
await session.commit()
-def _build_template_ctx(run: AutomationRun, step_outputs: dict[str, Any]) -> dict[str, Any]:
+def _build_template_ctx(
+ run: AutomationRun, step_outputs: dict[str, Any]
+) -> dict[str, Any]:
automation = run.automation
trigger = run.trigger
return build_run_context(
diff --git a/surfsense_backend/app/automations/runtime/step.py b/surfsense_backend/app/automations/runtime/step.py
index ac18b5e1f..6e7c9c671 100644
--- a/surfsense_backend/app/automations/runtime/step.py
+++ b/surfsense_backend/app/automations/runtime/step.py
@@ -30,14 +30,18 @@ async def execute_step(
try:
should_run = evaluate_predicate(step.when, template_context)
except Exception as exc:
- return _result(step, "failed", started_at, attempts=0, error=_error(exc, "when"))
+ return _result(
+ step, "failed", started_at, attempts=0, error=_error(exc, "when")
+ )
if not should_run:
return _result(step, "skipped", started_at, attempts=0)
try:
resolved_params = render_value(step.params, template_context)
except Exception as exc:
- return _result(step, "failed", started_at, attempts=0, error=_error(exc, "render"))
+ return _result(
+ step, "failed", started_at, attempts=0, error=_error(exc, "render")
+ )
action = get_action(step.action)
if action is None:
@@ -46,12 +50,17 @@ async def execute_step(
"failed",
started_at,
attempts=0,
- error={"message": f"action not registered: {step.action}", "type": "ActionNotFound"},
+ error={
+ "message": f"action not registered: {step.action}",
+ "type": "ActionNotFound",
+ },
)
handler = action.build_handler(action_context)
- max_retries = step.max_retries if step.max_retries is not None else default_max_retries
+ max_retries = (
+ step.max_retries if step.max_retries is not None else default_max_retries
+ )
timeout = step.timeout_seconds or default_timeout_seconds
try:
@@ -62,7 +71,9 @@ async def execute_step(
timeout=timeout,
)
except Exception as exc:
- return _result(step, "failed", started_at, attempts=max_retries + 1, error=_error(exc))
+ return _result(
+ step, "failed", started_at, attempts=max_retries + 1, error=_error(exc)
+ )
return _result(step, "succeeded", started_at, attempts=attempts, result=result)
diff --git a/surfsense_backend/app/automations/schemas/definition/execution.py b/surfsense_backend/app/automations/schemas/definition/execution.py
index 61861f8d8..bdbad62f8 100644
--- a/surfsense_backend/app/automations/schemas/definition/execution.py
+++ b/surfsense_backend/app/automations/schemas/definition/execution.py
@@ -12,7 +12,9 @@ from .plan_step import PlanStep
class Execution(BaseModel):
model_config = ConfigDict(extra="forbid")
- timeout_seconds: int = Field(default=600, gt=0, description="Wall-clock cap for the run.")
+ timeout_seconds: int = Field(
+ default=600, gt=0, description="Wall-clock cap for the run."
+ )
max_retries: int = Field(default=2, ge=0, description="Per-step retry budget.")
retry_backoff: Literal["exponential", "linear", "none"] = "exponential"
concurrency: Literal["drop_if_running", "queue", "always"] = "drop_if_running"
diff --git a/surfsense_backend/app/automations/schemas/definition/plan_step.py b/surfsense_backend/app/automations/schemas/definition/plan_step.py
index 5d16f1f3e..0d3bb9dfc 100644
--- a/surfsense_backend/app/automations/schemas/definition/plan_step.py
+++ b/surfsense_backend/app/automations/schemas/definition/plan_step.py
@@ -11,7 +11,9 @@ class PlanStep(BaseModel):
model_config = ConfigDict(extra="forbid")
step_id: str = Field(..., min_length=1, description="Unique within the plan.")
- action: str = Field(..., min_length=1, description="Action type; resolved via registry.")
+ action: str = Field(
+ ..., min_length=1, description="Action type; resolved via registry."
+ )
when: str | None = Field(
default=None,
description="Optional predicate; step is skipped when falsy.",
diff --git a/surfsense_backend/app/automations/schemas/definition/trigger_spec.py b/surfsense_backend/app/automations/schemas/definition/trigger_spec.py
index a359a2f63..e6a995bbf 100644
--- a/surfsense_backend/app/automations/schemas/definition/trigger_spec.py
+++ b/surfsense_backend/app/automations/schemas/definition/trigger_spec.py
@@ -10,7 +10,9 @@ from pydantic import BaseModel, ConfigDict, Field
class TriggerSpec(BaseModel):
model_config = ConfigDict(extra="forbid")
- type: str = Field(..., min_length=1, description="Trigger type; resolved via registry.")
+ type: str = Field(
+ ..., min_length=1, description="Trigger type; resolved via registry."
+ )
params: dict[str, Any] = Field(
default_factory=dict,
description="Type-specific params; validated against the trigger's schema.",
diff --git a/surfsense_backend/app/automations/services/automation.py b/surfsense_backend/app/automations/services/automation.py
index 9140da3b5..0d2937e0e 100644
--- a/surfsense_backend/app/automations/services/automation.py
+++ b/surfsense_backend/app/automations/services/automation.py
@@ -10,14 +10,14 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
+from app.automations.persistence.enums.trigger_type import TriggerType
+from app.automations.persistence.models.automation import Automation
+from app.automations.persistence.models.trigger import AutomationTrigger
from app.automations.schemas.api import (
AutomationCreate,
AutomationUpdate,
TriggerCreate,
)
-from app.automations.persistence.enums.trigger_type import TriggerType
-from app.automations.persistence.models.automation import Automation
-from app.automations.persistence.models.trigger import AutomationTrigger
from app.automations.triggers import get_trigger
from app.automations.triggers.schedule import compute_next_fire_at
from app.db import Permission, User, get_async_session
@@ -34,7 +34,9 @@ class AutomationService:
async def create(self, payload: AutomationCreate) -> Automation:
"""Create an automation and its initial triggers in one transaction."""
- await self._authorize(payload.search_space_id, Permission.AUTOMATIONS_CREATE.value)
+ await self._authorize(
+ payload.search_space_id, Permission.AUTOMATIONS_CREATE.value
+ )
automation = Automation(
search_space_id=payload.search_space_id,
@@ -67,22 +69,32 @@ class AutomationService:
)
rows = (
- await self.session.execute(
- base.order_by(Automation.created_at.desc()).limit(limit).offset(offset)
+ (
+ await self.session.execute(
+ base.order_by(Automation.created_at.desc())
+ .limit(limit)
+ .offset(offset)
+ )
)
- ).scalars().all()
+ .scalars()
+ .all()
+ )
return list(rows), int(total or 0)
async def get(self, automation_id: int) -> Automation:
"""Get an automation with its triggers loaded."""
automation = await self._get_with_triggers_or_raise(automation_id)
- await self._authorize(automation.search_space_id, Permission.AUTOMATIONS_READ.value)
+ await self._authorize(
+ automation.search_space_id, Permission.AUTOMATIONS_READ.value
+ )
return automation
async def update(self, automation_id: int, patch: AutomationUpdate) -> Automation:
"""Patch fields. Bumps ``version`` when ``definition`` changes."""
automation = await self._get_with_triggers_or_raise(automation_id)
- await self._authorize(automation.search_space_id, Permission.AUTOMATIONS_UPDATE.value)
+ await self._authorize(
+ automation.search_space_id, Permission.AUTOMATIONS_UPDATE.value
+ )
data = patch.model_dump(exclude_unset=True)
@@ -93,7 +105,9 @@ class AutomationService:
if "status" in data:
automation.status = data["status"]
if "definition" in data:
- automation.definition = patch.definition.model_dump(mode="json", by_alias=True)
+ automation.definition = patch.definition.model_dump(
+ mode="json", by_alias=True
+ )
automation.version += 1
await self.session.commit()
@@ -102,7 +116,9 @@ class AutomationService:
async def delete(self, automation_id: int) -> None:
"""Delete an automation; FK cascades remove triggers and runs."""
automation = await self._get_or_raise(automation_id)
- await self._authorize(automation.search_space_id, Permission.AUTOMATIONS_DELETE.value)
+ await self._authorize(
+ automation.search_space_id, Permission.AUTOMATIONS_DELETE.value
+ )
await self.session.delete(automation)
await self.session.commit()
@@ -141,7 +157,9 @@ def _build_trigger(spec: TriggerCreate) -> AutomationTrigger:
"""Validate trigger params via its registered Pydantic model and build the ORM row."""
definition = get_trigger(spec.type.value)
if definition is None:
- raise HTTPException(status_code=422, detail=f"unknown trigger type {spec.type.value!r}")
+ raise HTTPException(
+ status_code=422, detail=f"unknown trigger type {spec.type.value!r}"
+ )
try:
validated = definition.params_model.model_validate(spec.params)
diff --git a/surfsense_backend/app/automations/services/run.py b/surfsense_backend/app/automations/services/run.py
index ac9970241..3ef80416f 100644
--- a/surfsense_backend/app/automations/services/run.py
+++ b/surfsense_backend/app/automations/services/run.py
@@ -36,10 +36,16 @@ class RunService:
)
rows = (
- await self.session.execute(
- base.order_by(AutomationRun.created_at.desc()).limit(limit).offset(offset)
+ (
+ await self.session.execute(
+ base.order_by(AutomationRun.created_at.desc())
+ .limit(limit)
+ .offset(offset)
+ )
)
- ).scalars().all()
+ .scalars()
+ .all()
+ )
return list(rows), int(total or 0)
async def get(self, *, automation_id: int, run_id: int) -> AutomationRun:
diff --git a/surfsense_backend/app/automations/services/trigger.py b/surfsense_backend/app/automations/services/trigger.py
index c76cc0740..29ac84557 100644
--- a/surfsense_backend/app/automations/services/trigger.py
+++ b/surfsense_backend/app/automations/services/trigger.py
@@ -8,10 +8,10 @@ from fastapi import Depends, HTTPException
from pydantic import ValidationError
from sqlalchemy.ext.asyncio import AsyncSession
-from app.automations.schemas.api import TriggerCreate, TriggerUpdate
from app.automations.persistence.enums.trigger_type import TriggerType
from app.automations.persistence.models.automation import Automation
from app.automations.persistence.models.trigger import AutomationTrigger
+from app.automations.schemas.api import TriggerCreate, TriggerUpdate
from app.automations.triggers import get_trigger
from app.automations.triggers.schedule import compute_next_fire_at
from app.db import Permission, User, get_async_session
@@ -40,7 +40,9 @@ class TriggerService:
params=validated_params,
static_inputs=payload.static_inputs,
enabled=payload.enabled,
- next_fire_at=_initial_next_fire(payload.type, validated_params, payload.enabled),
+ next_fire_at=_initial_next_fire(
+ payload.type, validated_params, payload.enabled
+ ),
)
self.session.add(trigger)
await self.session.commit()
@@ -54,7 +56,9 @@ class TriggerService:
trigger_id: int,
patch: TriggerUpdate,
) -> AutomationTrigger:
- await self._authorize_automation(automation_id, Permission.AUTOMATIONS_UPDATE.value)
+ await self._authorize_automation(
+ automation_id, Permission.AUTOMATIONS_UPDATE.value
+ )
trigger = await self._get_trigger_or_raise(automation_id, trigger_id)
data = patch.model_dump(exclude_unset=True)
@@ -80,7 +84,9 @@ class TriggerService:
return trigger
async def remove(self, *, automation_id: int, trigger_id: int) -> None:
- await self._authorize_automation(automation_id, Permission.AUTOMATIONS_UPDATE.value)
+ await self._authorize_automation(
+ automation_id, Permission.AUTOMATIONS_UPDATE.value
+ )
trigger = await self._get_trigger_or_raise(automation_id, trigger_id)
await self.session.delete(trigger)
await self.session.commit()
diff --git a/surfsense_backend/app/automations/tasks/execute_run.py b/surfsense_backend/app/automations/tasks/execute_run.py
index 5fc84698b..ed448515d 100644
--- a/surfsense_backend/app/automations/tasks/execute_run.py
+++ b/surfsense_backend/app/automations/tasks/execute_run.py
@@ -17,7 +17,7 @@ TASK_NAME = "automation_run_execute"
@celery_app.task(name=TASK_NAME, bind=True)
-def automation_run_execute(self, run_id: int) -> None: # noqa: ARG001 — Celery bind
+def automation_run_execute(self, run_id: int) -> None:
"""Execute one ``AutomationRun``. Idempotent: terminal runs no-op."""
return run_async_celery_task(lambda: _impl(run_id))
diff --git a/surfsense_backend/app/automations/tasks/schedule_tick.py b/surfsense_backend/app/automations/tasks/schedule_tick.py
index 385bd7242..90fff66fc 100644
--- a/surfsense_backend/app/automations/tasks/schedule_tick.py
+++ b/surfsense_backend/app/automations/tasks/schedule_tick.py
@@ -103,9 +103,7 @@ async def _self_heal_null_next_fire(session: AsyncSession, *, now: datetime) ->
await session.commit()
-async def _claim_due_triggers(
- session: AsyncSession, *, now: datetime
-) -> list[_Claim]:
+async def _claim_due_triggers(session: AsyncSession, *, now: datetime) -> list[_Claim]:
"""Lock and advance due rows; return per-trigger fire context."""
stmt = (
select(AutomationTrigger)
diff --git a/surfsense_backend/app/automations/triggers/__init__.py b/surfsense_backend/app/automations/triggers/__init__.py
index d7abb6b5d..f630ebf6f 100644
--- a/surfsense_backend/app/automations/triggers/__init__.py
+++ b/surfsense_backend/app/automations/triggers/__init__.py
@@ -17,4 +17,4 @@ __all__ = [
]
# Built-in triggers self-register at import time.
-from . import schedule # noqa: E402, F401
+from . import schedule # noqa: F401
diff --git a/surfsense_backend/app/automations/triggers/schedule/__init__.py b/surfsense_backend/app/automations/triggers/schedule/__init__.py
index 5587692b9..92f478aac 100644
--- a/surfsense_backend/app/automations/triggers/schedule/__init__.py
+++ b/surfsense_backend/app/automations/triggers/schedule/__init__.py
@@ -15,4 +15,4 @@ __all__ = [
]
# Side-effect: register on the triggers store.
-from . import definition # noqa: E402, F401
+from . import definition # noqa: F401
diff --git a/surfsense_backend/app/automations/triggers/schedule/cron.py b/surfsense_backend/app/automations/triggers/schedule/cron.py
index 7155bab33..a8401e4a3 100644
--- a/surfsense_backend/app/automations/triggers/schedule/cron.py
+++ b/surfsense_backend/app/automations/triggers/schedule/cron.py
@@ -32,6 +32,10 @@ def compute_next_fire_at(cron: str, timezone: str, *, after: datetime) -> dateti
given timezone before evaluation so DST and IANA rules apply correctly.
"""
tz = ZoneInfo(timezone)
- base = after.astimezone(tz) if after.tzinfo else after.replace(tzinfo=UTC).astimezone(tz)
+ base = (
+ after.astimezone(tz)
+ if after.tzinfo
+ else after.replace(tzinfo=UTC).astimezone(tz)
+ )
nxt: datetime = croniter(cron, base).get_next(datetime)
return nxt.astimezone(UTC)
diff --git a/surfsense_backend/app/automations/triggers/schedule/params.py b/surfsense_backend/app/automations/triggers/schedule/params.py
index 21da84f68..f3945a1b8 100644
--- a/surfsense_backend/app/automations/triggers/schedule/params.py
+++ b/surfsense_backend/app/automations/triggers/schedule/params.py
@@ -10,7 +10,9 @@ from .cron import InvalidCronError, validate_cron
class ScheduleTriggerParams(BaseModel):
model_config = ConfigDict(extra="forbid")
- cron: str = Field(..., description="Five-field cron expression.", examples=["0 9 * * 1-5"])
+ cron: str = Field(
+ ..., description="Five-field cron expression.", examples=["0 9 * * 1-5"]
+ )
timezone: str = Field(..., description="IANA timezone.", examples=["Africa/Kigali"])
@model_validator(mode="after")
diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py
index ac880ded5..fe2e53268 100644
--- a/surfsense_backend/app/db.py
+++ b/surfsense_backend/app/db.py
@@ -2605,7 +2605,6 @@ from app.automations.persistence import ( # noqa: E402, F401
AutomationTrigger,
)
-
engine = create_async_engine(
DATABASE_URL,
pool_size=30,
diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py
index ef1c9312a..48a095456 100644
--- a/surfsense_backend/app/routes/__init__.py
+++ b/surfsense_backend/app/routes/__init__.py
@@ -1,5 +1,7 @@
from fastapi import APIRouter
+from app.automations.api import router as automations_router
+
from .agent_action_log_route import router as agent_action_log_router
from .agent_flags_route import router as agent_flags_router
from .agent_permissions_route import router as agent_permissions_router
@@ -7,7 +9,6 @@ from .agent_revert_route import router as agent_revert_router
from .airtable_add_connector_route import (
router as airtable_add_connector_router,
)
-from app.automations.api import router as automations_router
from .chat_comments_routes import router as chat_comments_router
from .circleback_webhook_route import router as circleback_webhook_router
from .clickup_add_connector_route import router as clickup_add_connector_router
diff --git a/surfsense_backend/app/tasks/chat/streaming/context/deepagents_todos.py b/surfsense_backend/app/tasks/chat/streaming/context/deepagents_todos.py
index 0bbf4f0a5..b9cbf6506 100644
--- a/surfsense_backend/app/tasks/chat/streaming/context/deepagents_todos.py
+++ b/surfsense_backend/app/tasks/chat/streaming/context/deepagents_todos.py
@@ -19,9 +19,7 @@ def extract_todos_from_deepagents(command_output: Any) -> dict:
elif isinstance(command_output, dict):
if "todos" in command_output:
todos_data = command_output.get("todos", [])
- elif "update" in command_output and isinstance(
- command_output["update"], dict
- ):
+ elif "update" in command_output and isinstance(command_output["update"], dict):
todos_data = command_output["update"].get("todos", [])
return {"todos": todos_data}
diff --git a/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/auto_pin.py b/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/auto_pin.py
index cb20eb011..af496cee7 100644
--- a/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/auto_pin.py
+++ b/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/auto_pin.py
@@ -69,17 +69,13 @@ async def resolve_initial_auto_pin(
"pin.requires_image_input": requires_image_input,
},
)
- return AutoPinResult(
- llm_config_id=pinned.resolved_llm_config_id, error=None
- )
+ return AutoPinResult(llm_config_id=pinned.resolved_llm_config_id, error=None)
except ValueError as pin_error:
# The "no vision-capable cfg" path raises a ValueError whose message
# we map to the friendly image-input SSE error so the user sees the
# same message regardless of whether the gate fired in the resolver or
# in ``llm_capability.assert_vision_capability_for_image_turn``.
- is_vision_failure = (
- requires_image_input and "vision-capable" in str(pin_error)
- )
+ is_vision_failure = requires_image_input and "vision-capable" in str(pin_error)
error_code = (
"MODEL_DOES_NOT_SUPPORT_IMAGE_INPUT"
if is_vision_failure
diff --git a/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/input_state.py b/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/input_state.py
index fb171c244..f508571b0 100644
--- a/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/input_state.py
+++ b/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/input_state.py
@@ -207,9 +207,7 @@ async def _resolve_mentions_for_query(
try:
chip_objs.append(MentionedDocumentInfo.model_validate(raw))
except Exception:
- logger.debug(
- "stream_new_chat: dropping malformed mention chip %r", raw
- )
+ logger.debug("stream_new_chat: dropping malformed mention chip %r", raw)
resolved = await resolve_mentions(
session,
diff --git a/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/llm_capability.py b/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/llm_capability.py
index ff5a56eec..9f4e5d2d8 100644
--- a/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/llm_capability.py
+++ b/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/llm_capability.py
@@ -48,9 +48,7 @@ def check_image_input_capability(
return None
model_label = agent_config.config_name or agent_config.model_name or "model"
- ot.add_event(
- "quota.denied", {"quota.code": "MODEL_DOES_NOT_SUPPORT_IMAGE_INPUT"}
- )
+ ot.add_event("quota.denied", {"quota.code": "MODEL_DOES_NOT_SUPPORT_IMAGE_INPUT"})
return (
(
f"The selected model ({model_label}) does not support "
diff --git a/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/orchestrator.py b/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/orchestrator.py
index bca72b5ea..6d0853502 100644
--- a/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/orchestrator.py
+++ b/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/orchestrator.py
@@ -259,7 +259,8 @@ async def stream_new_chat(
if needs_premium_quota(agent_config, user_id):
premium_reservation = await reserve_premium(
- agent_config=agent_config, user_id=user_id # type: ignore[arg-type]
+ agent_config=agent_config,
+ user_id=user_id, # type: ignore[arg-type]
)
if not premium_reservation.allowed:
ot.add_event("quota.denied", {"quota.code": "PREMIUM_QUOTA_EXHAUSTED"})
@@ -492,7 +493,9 @@ async def stream_new_chat(
# --- Block 4: First SSE frames ---
- for sse in iter_initial_frames(streaming_service, turn_id=stream_result.turn_id):
+ for sse in iter_initial_frames(
+ streaming_service, turn_id=stream_result.turn_id
+ ):
yield sse
# --- Block 5: Persistence join + message-id frames ---
@@ -693,7 +696,9 @@ async def stream_new_chat(
fallback_commit_search_space_id=search_space_id,
fallback_commit_created_by_id=user_id,
fallback_commit_filesystem_mode=(
- filesystem_selection.mode if filesystem_selection else FilesystemMode.CLOUD
+ filesystem_selection.mode
+ if filesystem_selection
+ else FilesystemMode.CLOUD
),
fallback_commit_thread_id=chat_id,
runtime_context=runtime_context,
@@ -715,11 +720,7 @@ async def stream_new_chat(
title_emitted = True
# Account for the case where the task completed but produced no
# title — flip the flag anyway so we don't keep checking it.
- if (
- title_task is not None
- and title_task.done()
- and not title_emitted
- ):
+ if title_task is not None and title_task.done() and not title_emitted:
title_emitted = True
_perf_log.info(
@@ -811,9 +812,7 @@ async def stream_new_chat(
end_turn(str(chat_id))
if premium_reservation is not None and user_id:
- await release_premium(
- reservation=premium_reservation, user_id=user_id
- )
+ await release_premium(reservation=premium_reservation, user_id=user_id)
await close_session_and_clear_ai_responding(session, chat_id)
@@ -852,9 +851,9 @@ async def stream_new_chat(
# Break circular refs held by the agent graph, tools, and LLM
# wrappers so the GC can reclaim them in a single pass.
- agent = llm = connector_service = None # noqa: F841
- input_state = stream_result = None # noqa: F841
- session = None # noqa: F841
+ agent = llm = connector_service = None
+ input_state = stream_result = None
+ session = None
run_gc_pass(log_prefix="stream_new_chat", chat_id=chat_id)
close_chat_request_span(
diff --git a/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/runtime_context.py b/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/runtime_context.py
index 1f11be1fe..cf1e8c3fb 100644
--- a/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/runtime_context.py
+++ b/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/runtime_context.py
@@ -30,9 +30,7 @@ def build_new_chat_runtime_context(
return SurfSenseContextSchema(
search_space_id=search_space_id,
mentioned_document_ids=list(mentioned_document_ids or []),
- mentioned_folder_ids=list(
- accepted_folder_ids or mentioned_folder_ids or []
- ),
+ mentioned_folder_ids=list(accepted_folder_ids or mentioned_folder_ids or []),
request_id=request_id,
turn_id=turn_id,
)
diff --git a/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/title_gen.py b/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/title_gen.py
index 11312110f..7db45941b 100644
--- a/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/title_gen.py
+++ b/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/title_gen.py
@@ -133,12 +133,8 @@ async def _generate_title(
# inherited Azure endpoint — see ``provider_api_base`` for the
# same bug repro on the image-gen / vision paths.
raw_model = getattr(llm, "model", "") or ""
- provider_prefix = (
- raw_model.split("/", 1)[0] if "/" in raw_model else None
- )
- provider_value = (
- agent_config.provider if agent_config is not None else None
- )
+ provider_prefix = raw_model.split("/", 1)[0] if "/" in raw_model else None
+ provider_value = agent_config.provider if agent_config is not None else None
title_api_base = resolve_api_base(
provider=provider_value,
provider_prefix=provider_prefix,
diff --git a/surfsense_backend/app/tasks/chat/streaming/flows/resume_chat/orchestrator.py b/surfsense_backend/app/tasks/chat/streaming/flows/resume_chat/orchestrator.py
index b67ac987e..e1b95aa63 100644
--- a/surfsense_backend/app/tasks/chat/streaming/flows/resume_chat/orchestrator.py
+++ b/surfsense_backend/app/tasks/chat/streaming/flows/resume_chat/orchestrator.py
@@ -15,14 +15,10 @@ building blocks under ``flows/shared/``. Mirrors ``stream_new_chat`` but:
from __future__ import annotations
import contextlib
-import gc
import logging
-import sys
import time
-import uuid as _uuid
from collections.abc import AsyncGenerator
from functools import partial
-from typing import Any
from uuid import UUID
import anyio
@@ -32,7 +28,7 @@ from app.agents.new_chat.chat_deepagent import create_surfsense_deep_agent
from app.agents.new_chat.filesystem_selection import FilesystemMode, FilesystemSelection
from app.agents.new_chat.middleware.busy_mutex import end_turn
from app.config import config as _app_config
-from app.db import ChatVisibility, async_session_maker, shielded_async_session
+from app.db import ChatVisibility, async_session_maker
from app.observability import otel as ot
from app.services.chat_session_state_service import set_ai_responding
from app.services.new_streaming_service import VercelStreamingService
@@ -89,7 +85,7 @@ from app.tasks.chat.streaming.flows.shared.terminal_error import (
)
from app.tasks.chat.streaming.shared.stream_result import StreamResult
from app.tasks.chat.streaming.shared.utils import resume_step_prefix
-from app.utils.perf import get_perf_logger, log_system_snapshot
+from app.utils.perf import get_perf_logger
logger = logging.getLogger(__name__)
_perf_log = get_perf_logger()
@@ -217,12 +213,11 @@ async def stream_resume_chat(
if needs_premium_quota(agent_config, user_id):
premium_reservation = await reserve_premium(
- agent_config=agent_config, user_id=user_id # type: ignore[arg-type]
+ agent_config=agent_config,
+ user_id=user_id, # type: ignore[arg-type]
)
if not premium_reservation.allowed:
- ot.add_event(
- "quota.denied", {"quota.code": "PREMIUM_QUOTA_EXHAUSTED"}
- )
+ ot.add_event("quota.denied", {"quota.code": "PREMIUM_QUOTA_EXHAUSTED"})
if requested_llm_config_id == 0:
try:
pinned_fb = await resolve_or_get_pinned_llm_config_id(
@@ -396,7 +391,9 @@ async def stream_resume_chat(
# --- First SSE frames ---
- for sse in iter_initial_frames(streaming_service, turn_id=stream_result.turn_id):
+ for sse in iter_initial_frames(
+ streaming_service, turn_id=stream_result.turn_id
+ ):
yield sse
# --- Assistant-shell persistence + id frame ---
@@ -517,7 +514,9 @@ async def stream_resume_chat(
fallback_commit_search_space_id=search_space_id,
fallback_commit_created_by_id=user_id,
fallback_commit_filesystem_mode=(
- filesystem_selection.mode if filesystem_selection else FilesystemMode.CLOUD
+ filesystem_selection.mode
+ if filesystem_selection
+ else FilesystemMode.CLOUD
),
fallback_commit_thread_id=chat_id,
runtime_context=runtime_context,
@@ -589,9 +588,7 @@ async def stream_resume_chat(
end_turn(str(chat_id))
if premium_reservation is not None and user_id:
- await release_premium(
- reservation=premium_reservation, user_id=user_id
- )
+ await release_premium(reservation=premium_reservation, user_id=user_id)
await close_session_and_clear_ai_responding(session, chat_id)
@@ -609,13 +606,11 @@ async def stream_resume_chat(
if not busy_error_raised:
with contextlib.suppress(Exception):
end_turn(str(chat_id))
- _perf_log.info(
- "[stream_resume] end_turn cleanup (chat_id=%s)", chat_id
- )
+ _perf_log.info("[stream_resume] end_turn cleanup (chat_id=%s)", chat_id)
- agent = llm = connector_service = None # noqa: F841
- stream_result = None # noqa: F841
- session = None # noqa: F841
+ agent = llm = connector_service = None
+ stream_result = None
+ session = None
run_gc_pass(log_prefix="stream_resume", chat_id=chat_id)
close_chat_request_span(
diff --git a/surfsense_backend/app/tasks/chat/streaming/flows/resume_chat/resume_routing.py b/surfsense_backend/app/tasks/chat/streaming/flows/resume_chat/resume_routing.py
index 300fbc9bd..7f4f67aac 100644
--- a/surfsense_backend/app/tasks/chat/streaming/flows/resume_chat/resume_routing.py
+++ b/surfsense_backend/app/tasks/chat/streaming/flows/resume_chat/resume_routing.py
@@ -47,9 +47,7 @@ async def build_resume_routing(
slice_decisions_by_tool_call,
)
- parent_state = await agent.aget_state(
- {"configurable": {"thread_id": str(chat_id)}}
- )
+ parent_state = await agent.aget_state({"configurable": {"thread_id": str(chat_id)}})
pending = collect_pending_tool_calls(parent_state)
_perf_log.info(
"[hitl_route] resume_entry chat_id=%s decisions=%d pending_subagents=%d",
diff --git a/surfsense_backend/app/tasks/chat/streaming/flows/shared/assistant_finalize.py b/surfsense_backend/app/tasks/chat/streaming/flows/shared/assistant_finalize.py
index d16f81ac7..be1f102f3 100644
--- a/surfsense_backend/app/tasks/chat/streaming/flows/shared/assistant_finalize.py
+++ b/surfsense_backend/app/tasks/chat/streaming/flows/shared/assistant_finalize.py
@@ -49,9 +49,7 @@ async def finalize_assistant_message(
was never assigned.
"""
if not (
- stream_result
- and stream_result.turn_id
- and stream_result.assistant_message_id
+ stream_result and stream_result.turn_id and stream_result.assistant_message_id
):
return
diff --git a/surfsense_backend/app/tasks/chat/streaming/flows/shared/finally_cleanup.py b/surfsense_backend/app/tasks/chat/streaming/flows/shared/finally_cleanup.py
index 8d425402f..f9454775e 100644
--- a/surfsense_backend/app/tasks/chat/streaming/flows/shared/finally_cleanup.py
+++ b/surfsense_backend/app/tasks/chat/streaming/flows/shared/finally_cleanup.py
@@ -39,9 +39,7 @@ async def close_session_and_clear_ai_responding(
async with shielded_async_session() as fresh_session:
await clear_ai_responding(fresh_session, chat_id)
except Exception:
- logger.warning(
- "Failed to clear AI responding state for thread %s", chat_id
- )
+ logger.warning("Failed to clear AI responding state for thread %s", chat_id)
with contextlib.suppress(Exception):
session.expunge_all()
diff --git a/surfsense_backend/app/tasks/chat/streaming/flows/shared/premium_quota.py b/surfsense_backend/app/tasks/chat/streaming/flows/shared/premium_quota.py
index 0ec40d275..cbf44764c 100644
--- a/surfsense_backend/app/tasks/chat/streaming/flows/shared/premium_quota.py
+++ b/surfsense_backend/app/tasks/chat/streaming/flows/shared/premium_quota.py
@@ -41,9 +41,7 @@ class PremiumReservation:
allowed: bool
-def needs_premium_quota(
- agent_config: AgentConfig | None, user_id: str | None
-) -> bool:
+def needs_premium_quota(agent_config: AgentConfig | None, user_id: str | None) -> bool:
return bool(agent_config is not None and user_id and agent_config.is_premium)
@@ -61,8 +59,10 @@ async def reserve_premium(
request_id = _uuid.uuid4().hex[:16]
litellm_params = agent_config.litellm_params or {}
base_model = (
- litellm_params.get("base_model") if isinstance(litellm_params, dict) else None
- ) or agent_config.model_name or ""
+ (litellm_params.get("base_model") if isinstance(litellm_params, dict) else None)
+ or agent_config.model_name
+ or ""
+ )
reserve_amount_micros = estimate_call_reserve_micros(
base_model=base_model,
quota_reserve_tokens=agent_config.quota_reserve_tokens,
diff --git a/surfsense_backend/app/tasks/chat/streaming/flows/shared/span.py b/surfsense_backend/app/tasks/chat/streaming/flows/shared/span.py
index 1e5169af1..74b9682ed 100644
--- a/surfsense_backend/app/tasks/chat/streaming/flows/shared/span.py
+++ b/surfsense_backend/app/tasks/chat/streaming/flows/shared/span.py
@@ -6,8 +6,7 @@ import contextlib
import sys
from typing import Any, Literal
-from app.observability import metrics as ot_metrics
-from app.observability import otel as ot
+from app.observability import metrics as ot_metrics, otel as ot
def open_chat_request_span(
diff --git a/surfsense_backend/app/tasks/chat/streaming/flows/shared/terminal_error.py b/surfsense_backend/app/tasks/chat/streaming/flows/shared/terminal_error.py
index c9db2caf2..b305dba23 100644
--- a/surfsense_backend/app/tasks/chat/streaming/flows/shared/terminal_error.py
+++ b/surfsense_backend/app/tasks/chat/streaming/flows/shared/terminal_error.py
@@ -15,8 +15,7 @@ from collections.abc import Iterator
from typing import Any, Literal
from app.agents.new_chat.errors import BusyError
-from app.observability import metrics as ot_metrics
-from app.observability import otel as ot
+from app.observability import metrics as ot_metrics, otel as ot
from app.services.new_streaming_service import VercelStreamingService
from app.tasks.chat.streaming.errors.classifier import classify_stream_exception
from app.tasks.chat.streaming.errors.emitter import emit_stream_terminal_error
diff --git a/surfsense_backend/tests/unit/automations/actions/agent_task/test_finalize.py b/surfsense_backend/tests/unit/automations/actions/agent_task/test_finalize.py
index bd49d764c..aa6c74549 100644
--- a/surfsense_backend/tests/unit/automations/actions/agent_task/test_finalize.py
+++ b/surfsense_backend/tests/unit/automations/actions/agent_task/test_finalize.py
@@ -72,7 +72,11 @@ def test_extract_returns_none_when_no_assistant_text_is_present() -> None:
anything?" rather than guess whether ``""`` means silence or empty
output. Empty-string contents are normalized to ``None`` too."""
no_ai = {"messages": [HumanMessage(content="just a question")]}
- only_tools = {"messages": [AIMessage(content=[{"type": "tool_use", "name": "x", "input": {}}])]}
+ only_tools = {
+ "messages": [
+ AIMessage(content=[{"type": "tool_use", "name": "x", "input": {}}])
+ ]
+ }
empty_string = {"messages": [AIMessage(content=" ")]}
assert extract_final_assistant_message(no_ai) is None
diff --git a/surfsense_backend/tests/unit/automations/runtime/test_retries.py b/surfsense_backend/tests/unit/automations/runtime/test_retries.py
index f0f12ca59..05fd02ab6 100644
--- a/surfsense_backend/tests/unit/automations/runtime/test_retries.py
+++ b/surfsense_backend/tests/unit/automations/runtime/test_retries.py
@@ -33,7 +33,9 @@ async def test_with_retries_returns_result_and_attempts_one_on_first_success() -
assert calls == 1
-async def test_with_retries_returns_attempt_count_when_succeeding_after_failures() -> None:
+async def test_with_retries_returns_attempt_count_when_succeeding_after_failures() -> (
+ None
+):
"""A coroutine that fails twice then succeeds returns ``attempts=3``
(the actual attempt that produced the result). Locks the contract
that the caller can distinguish first-try success from a recovery."""
diff --git a/surfsense_backend/tests/unit/automations/schemas/definition/test_envelope.py b/surfsense_backend/tests/unit/automations/schemas/definition/test_envelope.py
index c625b0ec9..d7b392a1d 100644
--- a/surfsense_backend/tests/unit/automations/schemas/definition/test_envelope.py
+++ b/surfsense_backend/tests/unit/automations/schemas/definition/test_envelope.py
@@ -11,7 +11,9 @@ from app.automations.schemas.definition.plan_step import PlanStep
pytestmark = pytest.mark.unit
-def test_automation_definition_accepts_minimal_valid_input_with_sensible_defaults() -> None:
+def test_automation_definition_accepts_minimal_valid_input_with_sensible_defaults() -> (
+ None
+):
"""A definition with just ``name`` + a one-step ``plan`` is valid and
fills in the rest with safe defaults so users don't have to write
out every section to get started."""
diff --git a/surfsense_backend/tests/unit/automations/templating/test_environment.py b/surfsense_backend/tests/unit/automations/templating/test_environment.py
index ec1c0ee40..64850c9c5 100644
--- a/surfsense_backend/tests/unit/automations/templating/test_environment.py
+++ b/surfsense_backend/tests/unit/automations/templating/test_environment.py
@@ -32,7 +32,9 @@ def test_environment_finalizes_datetime_output_to_iso_string() -> None:
when emitting ``inputs.fired_at`` and other datetime values."""
dt = datetime(2026, 5, 28, 14, 30, tzinfo=UTC)
- assert render_template("{{ moment }}", {"moment": dt}) == "2026-05-28T14:30:00+00:00"
+ assert (
+ render_template("{{ moment }}", {"moment": dt}) == "2026-05-28T14:30:00+00:00"
+ )
def test_environment_finalizes_none_output_to_empty_string() -> None:
diff --git a/surfsense_backend/tests/unit/automations/test_definition_types.py b/surfsense_backend/tests/unit/automations/test_definition_types.py
index 231e4fa97..2320b61d3 100644
--- a/surfsense_backend/tests/unit/automations/test_definition_types.py
+++ b/surfsense_backend/tests/unit/automations/test_definition_types.py
@@ -31,7 +31,7 @@ def test_action_definition_params_schema_reflects_params_model() -> None:
name="N",
description="D",
params_model=_Topic,
- build_handler=lambda _ctx: (lambda _p: {}), # type: ignore[arg-type,return-value]
+ build_handler=lambda _ctx: lambda _p: {}, # type: ignore[arg-type,return-value]
)
schema = definition.params_schema
diff --git a/surfsense_backend/tests/unit/automations/test_stores.py b/surfsense_backend/tests/unit/automations/test_stores.py
index e54062d64..d005d7be7 100644
--- a/surfsense_backend/tests/unit/automations/test_stores.py
+++ b/surfsense_backend/tests/unit/automations/test_stores.py
@@ -29,7 +29,9 @@ class _Params(BaseModel):
def _trigger(type_: str = "test_trigger") -> TriggerDefinition:
- return TriggerDefinition(type=type_, description="Test trigger.", params_model=_Params)
+ return TriggerDefinition(
+ type=type_, description="Test trigger.", params_model=_Params
+ )
def _action(type_: str = "test_action") -> ActionDefinition:
@@ -38,7 +40,7 @@ def _action(type_: str = "test_action") -> ActionDefinition:
name="Test",
description="Test action.",
params_model=_Params,
- build_handler=lambda _ctx: (lambda _p: {}), # type: ignore[arg-type,return-value]
+ build_handler=lambda _ctx: lambda _p: {}, # type: ignore[arg-type,return-value]
)
@@ -112,4 +114,4 @@ def test_all_triggers_returns_defensive_snapshot(
snapshot = all_triggers()
snapshot.pop("snapshot_test")
- assert get_trigger("snapshot_test") is not None
\ No newline at end of file
+ assert get_trigger("snapshot_test") is not None
diff --git a/surfsense_backend/tests/unit/automations/triggers/schedule/test_cron.py b/surfsense_backend/tests/unit/automations/triggers/schedule/test_cron.py
index 261e51b18..5c7580823 100644
--- a/surfsense_backend/tests/unit/automations/triggers/schedule/test_cron.py
+++ b/surfsense_backend/tests/unit/automations/triggers/schedule/test_cron.py
@@ -45,8 +45,12 @@ def test_compute_next_fire_at_respects_dst_offset_change() -> None:
winter_after = datetime(2026, 2, 15, 0, 0, tzinfo=UTC)
summer_after = datetime(2026, 4, 15, 0, 0, tzinfo=UTC)
- winter_fire = compute_next_fire_at("0 9 * * *", "America/New_York", after=winter_after)
- summer_fire = compute_next_fire_at("0 9 * * *", "America/New_York", after=summer_after)
+ winter_fire = compute_next_fire_at(
+ "0 9 * * *", "America/New_York", after=winter_after
+ )
+ summer_fire = compute_next_fire_at(
+ "0 9 * * *", "America/New_York", after=summer_after
+ )
assert winter_fire == datetime(2026, 2, 15, 14, 0, tzinfo=UTC)
assert summer_fire == datetime(2026, 4, 15, 13, 0, tzinfo=UTC)
diff --git a/surfsense_backend/tests/unit/tasks/chat/streaming/test_parallel_refactor_parity.py b/surfsense_backend/tests/unit/tasks/chat/streaming/test_parallel_refactor_parity.py
index eb24b4df8..ff4ca30df 100644
--- a/surfsense_backend/tests/unit/tasks/chat/streaming/test_parallel_refactor_parity.py
+++ b/surfsense_backend/tests/unit/tasks/chat/streaming/test_parallel_refactor_parity.py
@@ -33,7 +33,6 @@ import pytest
from app.agents.new_chat.context import SurfSenseContextSchema
from app.services.new_streaming_service import VercelStreamingService
-
from app.tasks.chat.stream_new_chat import (
stream_new_chat as old_stream_new_chat,
stream_resume_chat as old_stream_resume_chat,
@@ -152,7 +151,13 @@ class _FakeSurfsenseDoc:
"user_query, image_urls, docs, expected_title, expected_action",
[
("hello world", None, [], "Understanding your request", "Processing"),
- ("", ["data:image/png;base64,AAA"], [], "Understanding your request", "Processing"),
+ (
+ "",
+ ["data:image/png;base64,AAA"],
+ [],
+ "Understanding your request",
+ "Processing",
+ ),
("", None, [], "Understanding your request", "Processing"),
(
"doc question",
@@ -209,9 +214,10 @@ def test_initial_thinking_step_collapses_many_doc_names() -> None:
def test_image_capability_passes_without_images() -> None:
- assert check_image_input_capability(
- user_image_data_urls=None, agent_config=None
- ) is None
+ assert (
+ check_image_input_capability(user_image_data_urls=None, agent_config=None)
+ is None
+ )
def test_image_capability_passes_when_capability_unknown() -> None:
@@ -500,9 +506,7 @@ def test_can_recover_provider_rate_limit_rejects_non_rate_limit_exception() -> N
def test_spawn_set_ai_responding_bg_noop_without_user_id() -> None:
async def _run() -> set[asyncio.Task]:
background: set[asyncio.Task] = set()
- spawn_set_ai_responding_bg(
- chat_id=1, user_id=None, background_tasks=background
- )
+ spawn_set_ai_responding_bg(chat_id=1, user_id=None, background_tasks=background)
return background
bg = asyncio.run(_run())
diff --git a/surfsense_web/app/(home)/free/page.tsx b/surfsense_web/app/(home)/free/page.tsx
index 4512f3396..5cea9b6d2 100644
--- a/surfsense_web/app/(home)/free/page.tsx
+++ b/surfsense_web/app/(home)/free/page.tsx
@@ -221,10 +221,7 @@ export default async function FreeHubPage() {
{/* In-content ad: above the model table */}
-
+
@@ -353,10 +350,7 @@ export default async function FreeHubPage() {
{/* In-content ad: after CTA, before FAQ */}
-
+
diff --git a/surfsense_web/app/(home)/privacy/page.tsx b/surfsense_web/app/(home)/privacy/page.tsx
index 22833ae4a..cc7f64bee 100644
--- a/surfsense_web/app/(home)/privacy/page.tsx
+++ b/surfsense_web/app/(home)/privacy/page.tsx
@@ -37,9 +37,9 @@ export default function PrivacyPolicy() {
By accessing or using the Service, you acknowledge that you have read and understood
- this Privacy Policy. If you do not agree with our policies and practices, do not use
- the Service. We may modify this policy from time to time; material changes will be
- reflected by updating the "Last updated" date above.
+ this Privacy Policy. If you do not agree with our policies and practices, do not use the
+ Service. We may modify this policy from time to time; material changes will be reflected
+ by updating the "Last updated" date above.
@@ -71,9 +71,9 @@ export default function PrivacyPolicy() {
Notion, Confluence, GitHub, and others) under the scopes you authorize.
- Billing Data includes information necessary to process payments
- (such as transaction identifiers and credit balances). Card details are handled by
- our payment processor and are not stored on our servers.
+ Billing Data includes information necessary to process payments (such
+ as transaction identifiers and credit balances). Card details are handled by our
+ payment processor and are not stored on our servers.
Technical Data includes internet protocol (IP) address, browser type
@@ -126,8 +126,8 @@ export default function PrivacyPolicy() {
incidents.
- To communicate with you about product updates, security notices, support requests,
- and (with your consent where required) marketing.
+ To communicate with you about product updates, security notices, support requests, and
+ (with your consent where required) marketing.
To serve and measure advertising on pages where ads are shown (currently, our free
@@ -141,8 +141,8 @@ export default function PrivacyPolicy() {
4. Cookies and Tracking Technologies
We and our partners use cookies, local storage, and similar technologies to operate the
- Service, remember your preferences, measure usage, and serve advertising. The
- categories include:
+ Service, remember your preferences, measure usage, and serve advertising. The categories
+ include:
@@ -179,9 +179,9 @@ export default function PrivacyPolicy() {
- Google, as a third-party vendor, uses cookies (including the DoubleClick DART
- cookie) to serve ads to you based on your visits to our Service and other websites
- on the Internet.
+ Google, as a third-party vendor, uses cookies (including the DoubleClick DART cookie)
+ to serve ads to you based on your visits to our Service and other websites on the
+ Internet.
Google's use of advertising cookies enables it and its partners to serve ads to you
@@ -195,14 +195,12 @@ export default function PrivacyPolicy() {
youronlinechoices.com (EU).
- For users in the European Economic Area, the United Kingdom, and Switzerland, we
- use a Google-certified Consent Management Platform to obtain your consent for
- personalized advertising before such cookies are set. You may change or withdraw
- your consent at any time through the consent banner.
-
-
- We do not knowingly serve personalized advertising to children. See Section 11.
+ For users in the European Economic Area, the United Kingdom, and Switzerland, we use a
+ Google-certified Consent Management Platform to obtain your consent for personalized
+ advertising before such cookies are set. You may change or withdraw your consent at
+ any time through the consent banner.
+ We do not knowingly serve personalized advertising to children. See Section 11.
For more information about how Google uses data when you use our Service, see{" "}
@@ -217,8 +215,8 @@ export default function PrivacyPolicy() {
6. Data Security
We implement technical and organizational measures designed to protect your personal
- data against accidental loss, unauthorized access, alteration, and disclosure. Access
- to personal data is limited to personnel who need it to operate the Service.
+ data against accidental loss, unauthorized access, alteration, and disclosure. Access to
+ personal data is limited to personnel who need it to operate the Service.
No system can be guaranteed to be fully secure. We cannot guarantee that personal data
@@ -232,10 +230,10 @@ export default function PrivacyPolicy() {
We retain personal data only for as long as necessary to provide the Service and to
comply with our legal, accounting, and reporting obligations. Account data is retained
- for the life of your account; you can request deletion at any time. Aggregated data
- that no longer identifies you may be retained indefinitely for analytics and product
- improvement purposes. Anonymous chat sessions on our free pages are not retained in
- any user-linked database.
+ for the life of your account; you can request deletion at any time. Aggregated data that
+ no longer identifies you may be retained indefinitely for analytics and product
+ improvement purposes. Anonymous chat sessions on our free pages are not retained in any
+ user-linked database.
@@ -243,8 +241,7 @@ export default function PrivacyPolicy() {
8. Third-Party Services
We rely on the following categories of third-party processors and providers to operate
- the Service. Each is bound by its own privacy policy, which we encourage you to
- review:
+ the Service. Each is bound by its own privacy policy, which we encourage you to review:
@@ -261,9 +258,9 @@ export default function PrivacyPolicy() {
Advertising : Google AdSense (see Section 5).
- Large language model providers : OpenAI, Anthropic, Google, and
- other LLM providers process the prompts and content you submit to the Service in
- order to generate responses.
+ Large language model providers : OpenAI, Anthropic, Google, and other
+ LLM providers process the prompts and content you submit to the Service in order to
+ generate responses.
Integration providers : When you explicitly connect a third-party
@@ -278,9 +275,7 @@ export default function PrivacyPolicy() {
-
- 9. Your Legal Rights (Including GDPR)
-
+ 9. Your Legal Rights (Including GDPR)
Subject to applicable law, you have the following rights in relation to your personal
data:
@@ -314,17 +309,17 @@ export default function PrivacyPolicy() {
- The right to know what categories of personal information we have collected about
- you and how it is used and shared.
+ The right to know what categories of personal information we have collected about you
+ and how it is used and shared.
The right to delete personal information we have collected from you.
The right to correct inaccurate personal information.
The right to opt out of the "sale" or "sharing" of personal information for
cross-context behavioral advertising. We do not sell personal data; however,
- advertising cookies set by Google AdSense may be considered "sharing" under
- California law. To opt out, you can use the consent controls described in Section 5
- or enable a Global Privacy Control (GPC) signal in your browser, which we honor.
+ advertising cookies set by Google AdSense may be considered "sharing" under California
+ law. To opt out, you can use the consent controls described in Section 5 or enable a
+ Global Privacy Control (GPC) signal in your browser, which we honor.
The right not to be discriminated against for exercising your privacy rights.
@@ -337,33 +332,32 @@ export default function PrivacyPolicy() {
11. Children's Privacy
The Service is not directed to children under 13 (or under 16 in the EEA, UK, and
- Switzerland). We do not knowingly collect personal data from children. If you believe
- a child has provided us with personal data, please contact us and we will take steps
- to delete it. We do not knowingly serve personalized advertising to children.
+ Switzerland). We do not knowingly collect personal data from children. If you believe a
+ child has provided us with personal data, please contact us and we will take steps to
+ delete it. We do not knowingly serve personalized advertising to children.
12. Changes to This Policy
- We may update this Privacy Policy from time to time to reflect changes in our
- practices, technology, legal requirements, or for other operational reasons. When we
- make material changes, we will update the "Last updated" date at the top of this page
- and, where appropriate, provide additional notice (such as an in-product notification
- or email). Your continued use of the Service after the updated policy becomes
- effective constitutes your acceptance of the revised policy.
+ We may update this Privacy Policy from time to time to reflect changes in our practices,
+ technology, legal requirements, or for other operational reasons. When we make material
+ changes, we will update the "Last updated" date at the top of this page and, where
+ appropriate, provide additional notice (such as an in-product notification or email).
+ Your continued use of the Service after the updated policy becomes effective constitutes
+ your acceptance of the revised policy.
13. Contact Us
- If you have questions about this Privacy Policy or our privacy practices, or if you
- want to exercise any of your rights, please contact us at:
+ If you have questions about this Privacy Policy or our privacy practices, or if you want
+ to exercise any of your rights, please contact us at:
- Email: {" "}
- rohan@surfsense.com
+ Email: rohan@surfsense.com
diff --git a/surfsense_web/app/api/zero/query/route.ts b/surfsense_web/app/api/zero/query/route.ts
index 8caac9cd4..0e64c932f 100644
--- a/surfsense_web/app/api/zero/query/route.ts
+++ b/surfsense_web/app/api/zero/query/route.ts
@@ -1,10 +1,10 @@
import { mustGetQuery } from "@rocicorp/zero";
import { handleQueryRequest } from "@rocicorp/zero/server";
import { NextResponse } from "next/server";
+import { BACKEND_URL } from "@/lib/env-config";
import type { Context } from "@/types/zero";
import { queries } from "@/zero/queries";
import { schema } from "@/zero/schema";
-import { BACKEND_URL } from "@/lib/env-config";
const backendURL = BACKEND_URL;
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/automation-edit-content.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/automation-edit-content.tsx
index 219552a1a..6504af5a4 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/automation-edit-content.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/automation-edit-content.tsx
@@ -16,10 +16,7 @@ interface AutomationEditContentProps {
* structure but gates on ``canUpdate`` instead of ``canRead``: a user who
* can read but not update is bounced to the access-denied panel.
*/
-export function AutomationEditContent({
- searchSpaceId,
- automationId,
-}: AutomationEditContentProps) {
+export function AutomationEditContent({ searchSpaceId, automationId }: AutomationEditContentProps) {
const perms = useAutomationPermissions();
const validId = Number.isInteger(automationId) && automationId > 0;
const { data: automation, isLoading, error } = useAutomation(validId ? automationId : undefined);
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-form.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-form.tsx
index 86b355838..9b950608e 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-form.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-form.tsx
@@ -9,10 +9,7 @@ import { JsonView } from "@/components/json-view";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
-import {
- type Automation,
- automationUpdateRequest,
-} from "@/contracts/types/automation.types";
+import { type Automation, automationUpdateRequest } from "@/contracts/types/automation.types";
interface AutomationEditFormProps {
automation: Automation;
diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
index 6cd95a79c..06a069a4c 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
@@ -69,11 +69,11 @@ import { documentsApiService } from "@/lib/apis/documents-api.service";
import { getBearerToken } from "@/lib/auth-utils";
import { type ChatFlow, classifyChatError } from "@/lib/chat/chat-error-classifier";
import { tagPreAcceptSendFailure, toHttpResponseError } from "@/lib/chat/chat-request-errors";
+import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import {
convertToThreadMessage,
reconcileInterruptedAssistantMessages,
} from "@/lib/chat/message-utils";
-import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import {
isPodcastGenerating,
looksLikePodcastRequest,
@@ -110,6 +110,7 @@ import {
extractUserTurnForNewChatApi,
type NewChatUserImagePayload,
} from "@/lib/chat/user-turn-api-parts";
+import { BACKEND_URL } from "@/lib/env-config";
import { NotFoundError } from "@/lib/error";
import {
trackChatBlocked,
@@ -119,7 +120,7 @@ import {
trackChatResponseReceived,
} from "@/lib/posthog/events";
import Loading from "../loading";
-import { BACKEND_URL } from "@/lib/env-config";
+
const MobileEditorPanel = dynamic(
() =>
import("@/components/editor-panel/editor-panel").then((m) => ({
@@ -1977,14 +1978,12 @@ export default function NewChatPage() {
mentioned_folder_ids: regenerateFolderIds.length > 0 ? regenerateFolderIds : undefined,
mentioned_connector_ids:
regenerateConnectors.length > 0 ? regenerateConnectors.map((d) => d.id) : undefined,
- mentioned_connectors:
- regenerateConnectors.length > 0 ? regenerateConnectors : undefined,
+ mentioned_connectors: regenerateConnectors.length > 0 ? regenerateConnectors : undefined,
// Full mention metadata for the regenerate-specific
// source list. Only meaningful for edit (the BE only
// re-persists a user row when ``user_query`` is set);
// reload reuses the original turn's mentioned_documents.
- mentioned_documents:
- sourceMentionedDocs.length > 0 ? sourceMentionedDocs : undefined,
+ mentioned_documents: sourceMentionedDocs.length > 0 ? sourceMentionedDocs : undefined,
};
if (isEdit) {
requestBody.user_images = editExtras?.userImages ?? [];
diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/team-content.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/team-content.tsx
index 17c1dd121..3bc2459c1 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/team/team-content.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/team/team-content.tsx
@@ -31,7 +31,7 @@ import {
deleteMemberMutationAtom,
updateMemberMutationAtom,
} from "@/atoms/members/members-mutation.atoms";
-import { membersAtom, myAccessAtom, canPerform } from "@/atoms/members/members-query.atoms";
+import { canPerform, membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
import {
AlertDialog,
AlertDialogAction,
diff --git a/surfsense_web/atoms/members/members-query.atoms.ts b/surfsense_web/atoms/members/members-query.atoms.ts
index f8e4b2cf6..c1f507ff5 100644
--- a/surfsense_web/atoms/members/members-query.atoms.ts
+++ b/surfsense_web/atoms/members/members-query.atoms.ts
@@ -42,11 +42,11 @@ export const myAccessAtom = atomWithQuery((get) => {
/**
* Helper function to check if the current user has a specific permission.
- *
+ *
* @param access - The access object from useAtomValue(myAccessAtom)
* @param permission - The permission string to check
* @returns boolean indicating if the user has the permission
- *
+ *
* @example
* const access = useAtomValue(myAccessAtom);
* if (canPerform(access, 'manage_members')) { ... }
@@ -63,10 +63,10 @@ export function canPerform(
/**
* Hook wrapper for canPerform that reads from myAccessAtom internally.
* Use this if you want to avoid calling useAtomValue(myAccessAtom) separately.
- *
+ *
* @param permission - The permission string to check
* @returns boolean indicating if the user has the permission
- *
+ *
* @example
* const canManageMembers = usePermissionGate('manage_members');
*/
diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx
index f6c91e8bf..fd24600c2 100644
--- a/surfsense_web/components/assistant-ui/assistant-message.tsx
+++ b/surfsense_web/components/assistant-ui/assistant-message.tsx
@@ -13,8 +13,8 @@ import {
CheckIcon,
ClipboardPaste,
CopyIcon,
- DownloadIcon,
Dot,
+ DownloadIcon,
ExternalLink,
Globe,
MessageCircleReply,
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/obsidian-connect-form.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/obsidian-connect-form.tsx
index 01b86a538..a9231d846 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/obsidian-connect-form.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/obsidian-connect-form.tsx
@@ -6,14 +6,13 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { useApiKey } from "@/hooks/use-api-key";
+import { BACKEND_URL } from "@/lib/env-config";
import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index";
-import { BACKEND_URL } from "@/lib/env-config";
const PLUGIN_RELEASES_URL =
"https://github.com/MODSetter/SurfSense/releases?q=obsidian&expanded=true";
-
/**
* Obsidian connect form for the plugin-only architecture.
*
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/circleback-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/circleback-config.tsx
index fe6724ed8..4de8500a6 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/circleback-config.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/circleback-config.tsx
@@ -9,8 +9,8 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { authenticatedFetch } from "@/lib/auth-utils";
-import type { ConnectorConfigProps } from "../index";
import { BACKEND_URL } from "@/lib/env-config";
+import type { ConnectorConfigProps } from "../index";
export interface CirclebackConfigProps extends ConnectorConfigProps {
onNameChange?: (name: string) => void;
}
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx
index 3e9e9bb27..2b86daf65 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx
@@ -23,6 +23,7 @@ import { 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";
+
const VISION_LLM_CONNECTOR_TYPES = new Set([
EnumConnectorName.GOOGLE_DRIVE_CONNECTOR,
EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
diff --git a/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx
index 27e102d7e..05b684397 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx
@@ -18,6 +18,7 @@ import { cn } from "@/lib/utils";
import { LIVE_CONNECTOR_TYPES } from "../constants/connector-constants";
import { useConnectorStatus } from "../hooks/use-connector-status";
import { getConnectorDisplayName } from "../tabs/all-connectors-tab";
+
interface ConnectorAccountsListViewProps {
connectorType: string;
connectorTitle: string;
diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx
index c0d9d9212..52e015c56 100644
--- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx
+++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx
@@ -97,7 +97,12 @@ interface InlineMentionEditorProps {
onActionClose?: () => void;
onSubmit?: () => void;
onChange?: (text: string, docs: MentionedDocument[]) => void;
- onDocumentRemove?: (docId: number, docType?: string, kind?: MentionKind, connectorType?: string) => void;
+ onDocumentRemove?: (
+ docId: number,
+ docType?: string,
+ kind?: MentionKind,
+ connectorType?: string
+ ) => void;
onKeyDown?: (e: React.KeyboardEvent) => void;
disabled?: boolean;
className?: string;
@@ -171,9 +176,10 @@ const MentionElement: FC> = ({
{isFolder ? (
) : isConnector ? (
- getConnectorIcon(element.connector_type ?? element.document_type ?? "UNKNOWN", "h-3 w-3") ?? (
-
- )
+ (getConnectorIcon(
+ element.connector_type ?? element.document_type ?? "UNKNOWN",
+ "h-3 w-3"
+ ) ?? )
) : (
getConnectorIcon(element.document_type ?? "UNKNOWN", "h-3 w-3")
)}
@@ -357,7 +363,11 @@ function getSelectionAnchorRect(root: HTMLElement | null): SuggestionAnchorRect
const rect = range.getClientRects()[0] ?? range.getBoundingClientRect();
if (rect.width > 0 || rect.height > 0) return rectToAnchor(rect);
- if (range.collapsed && range.startContainer.nodeType === Node.TEXT_NODE && range.startOffset > 0) {
+ if (
+ range.collapsed &&
+ range.startContainer.nodeType === Node.TEXT_NODE &&
+ range.startOffset > 0
+ ) {
const fallbackRange = range.cloneRange();
fallbackRange.setStart(range.startContainer, range.startOffset - 1);
fallbackRange.setEnd(range.startContainer, range.startOffset);
diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx
index 5c5f99940..9abcfbb49 100644
--- a/surfsense_web/components/assistant-ui/thread.tsx
+++ b/surfsense_web/components/assistant-ui/thread.tsx
@@ -68,11 +68,6 @@ import {
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { UserMessage } from "@/components/assistant-ui/user-message";
import { ComposerSuggestionPopoverContent } from "@/components/new-chat/composer-suggestion-popup";
-import {
- DocumentMentionPicker,
- promoteRecentMention,
- type DocumentMentionPickerRef,
-} from "../new-chat/document-mention-picker";
import { PromptPicker, type PromptPickerRef } from "@/components/new-chat/prompt-picker";
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
@@ -112,6 +107,11 @@ import { captureDisplayToPngDataUrl } from "@/lib/chat/display-media-capture";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { slideoutOpenedTickAtom } from "@/lib/layout-events";
import { cn } from "@/lib/utils";
+import {
+ DocumentMentionPicker,
+ type DocumentMentionPickerRef,
+ promoteRecentMention,
+} from "../new-chat/document-mention-picker";
const COMPOSER_PLACEHOLDER = "Ask anything, type / for prompts, type @ to mention docs";
@@ -601,21 +601,24 @@ const Composer: FC = () => {
}
}, []);
- const handleActionTrigger = useCallback((trigger: SuggestionTriggerInfo) => {
- const anchorPoint = getComposerSuggestionAnchorPoint(
- trigger.anchorRect,
- clipboardInitialText ? "bottom" : "top"
- );
- if (!anchorPoint) {
- setShowPromptPicker(false);
- setActionQuery("");
- setSuggestionAnchorPoint(null);
- return;
- }
- setSuggestionAnchorPoint((current) => current ?? anchorPoint);
- setShowPromptPicker(true);
- setActionQuery(trigger.query);
- }, [clipboardInitialText]);
+ const handleActionTrigger = useCallback(
+ (trigger: SuggestionTriggerInfo) => {
+ const anchorPoint = getComposerSuggestionAnchorPoint(
+ trigger.anchorRect,
+ clipboardInitialText ? "bottom" : "top"
+ );
+ if (!anchorPoint) {
+ setShowPromptPicker(false);
+ setActionQuery("");
+ setSuggestionAnchorPoint(null);
+ return;
+ }
+ setSuggestionAnchorPoint((current) => current ?? anchorPoint);
+ setShowPromptPicker(true);
+ setActionQuery(trigger.query);
+ },
+ [clipboardInitialText]
+ );
const handleActionClose = useCallback(() => {
if (showPromptPicker) {
@@ -754,7 +757,12 @@ const Composer: FC = () => {
]);
const handleDocumentRemove = useCallback(
- (docId: number, docType?: string, kind?: "doc" | "folder" | "connector", connectorType?: string) => {
+ (
+ docId: number,
+ docType?: string,
+ kind?: "doc" | "folder" | "connector",
+ connectorType?: string
+ ) => {
setMentionedDocuments((prev) => {
const removedKey = getMentionDocKey({
id: docId,
@@ -768,27 +776,30 @@ const Composer: FC = () => {
[setMentionedDocuments]
);
- const handleDocumentsMention = useCallback((mentions: MentionedDocumentInfo[]) => {
- const parsedSearchSpaceId = Number(search_space_id);
- const editorMentionedDocs = editorRef.current?.getMentionedDocuments() ?? [];
- const editorDocKeys = new Set(editorMentionedDocs.map((doc) => getMentionDocKey(doc)));
+ const handleDocumentsMention = useCallback(
+ (mentions: MentionedDocumentInfo[]) => {
+ const parsedSearchSpaceId = Number(search_space_id);
+ const editorMentionedDocs = editorRef.current?.getMentionedDocuments() ?? [];
+ const editorDocKeys = new Set(editorMentionedDocs.map((doc) => getMentionDocKey(doc)));
- for (const mention of mentions) {
- const key = getMentionDocKey(mention);
- if (editorDocKeys.has(key)) continue;
- editorRef.current?.insertMentionChip(mention);
- if (Number.isFinite(parsedSearchSpaceId)) {
- promoteRecentMention(parsedSearchSpaceId, mention);
+ for (const mention of mentions) {
+ const key = getMentionDocKey(mention);
+ if (editorDocKeys.has(key)) continue;
+ editorRef.current?.insertMentionChip(mention);
+ if (Number.isFinite(parsedSearchSpaceId)) {
+ promoteRecentMention(parsedSearchSpaceId, mention);
+ }
+ // Track within the loop so a duplicate-in-batch can't double-insert.
+ editorDocKeys.add(key);
}
- // Track within the loop so a duplicate-in-batch can't double-insert.
- editorDocKeys.add(key);
- }
- // Atom is reconciled by ``handleEditorChange`` via the editor's
- // onChange — no second write path here.
- setMentionQuery("");
- setSuggestionAnchorPoint(null);
- }, [search_space_id]);
+ // Atom is reconciled by ``handleEditorChange`` via the editor's
+ // onChange — no second write path here.
+ setMentionQuery("");
+ setSuggestionAnchorPoint(null);
+ },
+ [search_space_id]
+ );
useEffect(() => {
const editor = editorRef.current;
diff --git a/surfsense_web/components/assistant-ui/user-message.tsx b/surfsense_web/components/assistant-ui/user-message.tsx
index b30db5f69..5c90dce55 100644
--- a/surfsense_web/components/assistant-ui/user-message.tsx
+++ b/surfsense_web/components/assistant-ui/user-message.tsx
@@ -104,9 +104,9 @@ const UserTextPart: FC = () => {
const icon = isFolder ? (
) : isConnector ? (
- getConnectorIcon(segment.doc.connector_type, "size-3.5") ?? (
+ (getConnectorIcon(segment.doc.connector_type, "size-3.5") ?? (
- )
+ ))
) : (
getConnectorIcon(segment.doc.document_type ?? "UNKNOWN", "size-3.5")
);
@@ -123,7 +123,9 @@ const UserTextPart: FC = () => {
: segment.doc.title
}
onClick={
- isFolder || isConnector ? undefined : () => handleOpenDoc(segment.doc.id, segment.doc.title)
+ isFolder || isConnector
+ ? undefined
+ : () => handleOpenDoc(segment.doc.id, segment.doc.title)
}
className="mx-0.5"
/>
diff --git a/surfsense_web/components/editor-panel/editor-panel.tsx b/surfsense_web/components/editor-panel/editor-panel.tsx
index 2baffaf0f..534ff9daa 100644
--- a/surfsense_web/components/editor-panel/editor-panel.tsx
+++ b/surfsense_web/components/editor-panel/editor-panel.tsx
@@ -34,6 +34,7 @@ import { useElectronAPI } from "@/hooks/use-platform";
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
import { inferMonacoLanguageFromPath } from "@/lib/editor-language";
import { BACKEND_URL } from "@/lib/env-config";
+
const PlateEditor = dynamic(
() => import("@/components/editor/plate-editor").then((m) => ({ default: m.PlateEditor })),
{ ssr: false, loading: () => }
diff --git a/surfsense_web/components/free-chat/anonymous-chat.tsx b/surfsense_web/components/free-chat/anonymous-chat.tsx
index 28ee1f6f0..aff58f7bc 100644
--- a/surfsense_web/components/free-chat/anonymous-chat.tsx
+++ b/surfsense_web/components/free-chat/anonymous-chat.tsx
@@ -6,11 +6,12 @@ import { Button } from "@/components/ui/button";
import type { AnonModel, AnonQuotaResponse } from "@/contracts/types/anonymous-chat.types";
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
import { readSSEStream } from "@/lib/chat/streaming-state";
+import { BACKEND_URL } from "@/lib/env-config";
import { trackAnonymousChatMessageSent } from "@/lib/posthog/events";
import { cn } from "@/lib/utils";
import { QuotaBar } from "./quota-bar";
import { QuotaWarningBanner } from "./quota-warning-banner";
-import { BACKEND_URL } from "@/lib/env-config";
+
interface Message {
id: string;
role: "user" | "assistant";
@@ -80,19 +81,16 @@ export function AnonymousChat({ model }: AnonymousChatProps) {
content: m.content,
}));
- const response = await fetch(
- `${BACKEND_URL}/api/v1/public/anon-chat/stream`,
- {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- credentials: "include",
- body: JSON.stringify({
- model_slug: modelSlug,
- messages: chatHistory,
- }),
- signal: controller.signal,
- }
- );
+ const response = await fetch(`${BACKEND_URL}/api/v1/public/anon-chat/stream`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ credentials: "include",
+ body: JSON.stringify({
+ model_slug: modelSlug,
+ messages: chatHistory,
+ }),
+ signal: controller.signal,
+ });
if (!response.ok) {
if (response.status === 429) {
diff --git a/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx b/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx
index 0aed0db61..b90a3b2a9 100644
--- a/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx
+++ b/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx
@@ -193,11 +193,7 @@ export function SearchSpaceAvatar({
// If delete or settings handlers are provided, expose them through a dropdown menu.
if (onDelete || onSettings) {
- const trigger = (
-
- {avatarButton(true)}
-
- );
+ const trigger = {avatarButton(true)} ;
return (
diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx
index ca90ba9b9..757ee2fc2 100644
--- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx
@@ -78,11 +78,11 @@ import { foldersApiService } from "@/lib/apis/folders-api.service";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
+import { BACKEND_URL } from "@/lib/env-config";
import { uploadFolderScan } from "@/lib/folder-sync-upload";
import { getSupportedExtensionsSet } from "@/lib/supported-extensions";
import { queries } from "@/zero/queries/index";
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
-import { BACKEND_URL } from "@/lib/env-config";
const DesktopLocalTabContent = dynamic(
() => import("./DesktopLocalTabContent").then((mod) => mod.DesktopLocalTabContent),
diff --git a/surfsense_web/components/layout/ui/tabs/DocumentTabContent.tsx b/surfsense_web/components/layout/ui/tabs/DocumentTabContent.tsx
index ef51eee3c..34cf707b0 100644
--- a/surfsense_web/components/layout/ui/tabs/DocumentTabContent.tsx
+++ b/surfsense_web/components/layout/ui/tabs/DocumentTabContent.tsx
@@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
import { BACKEND_URL } from "@/lib/env-config";
+
const LARGE_DOCUMENT_THRESHOLD = 2 * 1024 * 1024; // 2MB
interface DocumentContent {
diff --git a/surfsense_web/components/new-chat/composer-suggestion-popup.tsx b/surfsense_web/components/new-chat/composer-suggestion-popup.tsx
index 3fdf48875..7a30f487b 100644
--- a/surfsense_web/components/new-chat/composer-suggestion-popup.tsx
+++ b/surfsense_web/components/new-chat/composer-suggestion-popup.tsx
@@ -117,7 +117,10 @@ const ComposerSuggestionItem = React.forwardRef<
));
ComposerSuggestionItem.displayName = "ComposerSuggestionItem";
-function ComposerSuggestionSeparator({ className, ...props }: React.ComponentProps) {
+function ComposerSuggestionSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
return (
diff --git a/surfsense_web/components/new-chat/document-mention-picker.tsx b/surfsense_web/components/new-chat/document-mention-picker.tsx
index c26e51922..c2a0f1665 100644
--- a/surfsense_web/components/new-chat/document-mention-picker.tsx
+++ b/surfsense_web/components/new-chat/document-mention-picker.tsx
@@ -2,6 +2,7 @@
import { useQuery as useZeroQuery } from "@rocicorp/zero/react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
+import { useAtomValue } from "jotai";
import {
BookOpen,
ChevronLeft,
@@ -22,7 +23,6 @@ import {
useState,
} from "react";
import type { MentionedDocumentInfo } from "@/atoms/chat/mentioned-documents.atom";
-import { useAtomValue } from "jotai";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { getConnectorTitle } from "@/components/assistant-ui/connector-popup/constants/connector-constants";
import { getConnectorDisplayName } from "@/components/assistant-ui/connector-popup/tabs/all-connectors-tab";
@@ -178,7 +178,9 @@ function useDebounced
(value: T, delay = DEBOUNCE_MS) {
return debounced;
}
-function makeDocMention(doc: Pick): MentionedDocumentInfo {
+function makeDocMention(
+ doc: Pick
+): MentionedDocumentInfo {
return {
id: doc.id,
title: doc.title,
@@ -187,9 +189,10 @@ function makeDocMention(doc: Pick):
};
}
-function makeFolderMention(
- folder: { id: number; title: string }
-): Extract {
+function makeFolderMention(folder: {
+ id: number;
+ title: string;
+}): Extract {
return {
id: folder.id,
title: folder.title,
@@ -319,24 +322,24 @@ export const DocumentMentionPicker = forwardRef<
useEffect(() => {
if (currentPage !== 0) return;
- const combinedDocs: Pick[] = [];
+ const combinedDocs: Pick[] = [];
- if (surfsenseDocs?.items) {
- for (const doc of surfsenseDocs.items) {
- combinedDocs.push({
- id: doc.id,
- title: doc.title,
- document_type: "SURFSENSE_DOCS",
- });
- }
+ if (surfsenseDocs?.items) {
+ for (const doc of surfsenseDocs.items) {
+ combinedDocs.push({
+ id: doc.id,
+ title: doc.title,
+ document_type: "SURFSENSE_DOCS",
+ });
}
+ }
- if (titleSearchResults?.items) {
- combinedDocs.push(...titleSearchResults.items);
- setHasMore(titleSearchResults.has_more);
- }
+ if (titleSearchResults?.items) {
+ combinedDocs.push(...titleSearchResults.items);
+ setHasMore(titleSearchResults.has_more);
+ }
- setAccumulatedDocuments(filterBySearchTerm(combinedDocs));
+ setAccumulatedDocuments(filterBySearchTerm(combinedDocs));
}, [titleSearchResults, surfsenseDocs, currentPage, filterBySearchTerm]);
const loadNextPage = useCallback(async () => {
@@ -352,9 +355,11 @@ export const DocumentMentionPicker = forwardRef<
page_size: PAGE_SIZE,
...(isSearchValid ? { title: debouncedSearch.trim() } : {}),
};
- const response: SearchDocumentTitlesResponse = await documentsApiService.searchDocumentTitles({
- queryParams,
- });
+ const response: SearchDocumentTitlesResponse = await documentsApiService.searchDocumentTitles(
+ {
+ queryParams,
+ }
+ );
setAccumulatedDocuments((prev) => [...prev, ...response.items]);
setHasMore(response.has_more);
@@ -431,7 +436,13 @@ export const DocumentMentionPicker = forwardRef<
)
.filter((mention): mention is MentionedDocumentInfo => mention !== null)
.slice(0, RECENTS_LIMIT),
- [activeConnectors, hasHydratedRecentDocs, recentMentions, recentValidationDocuments, zeroFolders]
+ [
+ activeConnectors,
+ hasHydratedRecentDocs,
+ recentMentions,
+ recentValidationDocuments,
+ zeroFolders,
+ ]
);
const selectedKeys = useMemo(
@@ -460,47 +471,46 @@ export const DocumentMentionPicker = forwardRef<
[visibleRecentMentions, selectedKeys]
);
- const rootNodes = useMemo[]>(
- () => {
- const nodes: ComposerSuggestionNode[] = [...recentRootNodes];
- if (showSurfsenseDocsRoot) {
- nodes.push({
- id: "surfsense-docs",
- label: "SurfSense Docs",
- subtitle: "Browse product documentation",
- icon: ,
- type: "branch",
- value: { kind: "view", view: { kind: "surfsense-docs" } },
- });
- }
- nodes.push(
- {
- id: "files-folders",
- label: "Files & Folders",
- subtitle: "Browse your knowledge base",
- icon: ,
- type: "branch",
- value: { kind: "view", view: { kind: "files-folders" } },
- },
- {
- id: "connectors",
- label: "Connectors",
- subtitle: activeConnectors.length
- ? "Choose the exact account for tool use"
- : "No connected accounts yet",
+ const rootNodes = useMemo[]>(() => {
+ const nodes: ComposerSuggestionNode[] = [...recentRootNodes];
+ if (showSurfsenseDocsRoot) {
+ nodes.push({
+ id: "surfsense-docs",
+ label: "SurfSense Docs",
+ subtitle: "Browse product documentation",
+ icon: ,
+ type: "branch",
+ value: { kind: "view", view: { kind: "surfsense-docs" } },
+ });
+ }
+ nodes.push(
+ {
+ id: "files-folders",
+ label: "Files & Folders",
+ subtitle: "Browse your knowledge base",
+ icon: ,
+ type: "branch",
+ value: { kind: "view", view: { kind: "files-folders" } },
+ },
+ {
+ id: "connectors",
+ label: "Connectors",
+ subtitle: activeConnectors.length
+ ? "Choose the exact account for tool use"
+ : "No connected accounts yet",
icon: ,
- type: "branch",
- disabled: activeConnectors.length === 0,
- value: { kind: "view", view: { kind: "connectors" } },
- }
- );
- return nodes;
- },
- [activeConnectors.length, recentRootNodes, showSurfsenseDocsRoot]
- );
+ type: "branch",
+ disabled: activeConnectors.length === 0,
+ value: { kind: "view", view: { kind: "connectors" } },
+ }
+ );
+ return nodes;
+ }, [activeConnectors.length, recentRootNodes, showSurfsenseDocsRoot]);
const searchNodes = useMemo[]>(() => {
- const searchLower = (isSingleCharSearch ? deferredSearch : debouncedSearch).trim().toLowerCase();
+ const searchLower = (isSingleCharSearch ? deferredSearch : debouncedSearch)
+ .trim()
+ .toLowerCase();
const docNodes = actualDocuments.map((doc) => {
const mention = makeDocMention(doc);
return {
@@ -619,7 +629,9 @@ export const DocumentMentionPicker = forwardRef<
id: getMentionDocKey(mention),
label: getConnectorDisplayName(connector.name),
subtitle: `${view.title} account`,
- icon: getConnectorIcon(connector.connector_type, "size-4") ?? ,
+ icon: getConnectorIcon(connector.connector_type, "size-4") ?? (
+
+ ),
type: "item" as const,
disabled: selectedKeys.has(getMentionDocKey(mention)),
value: { kind: "mention" as const, mention },
@@ -733,7 +745,7 @@ export const DocumentMentionPicker = forwardRef<
icon={
-
+
}
>
{title}
@@ -759,7 +771,7 @@ export const DocumentMentionPicker = forwardRef<
return (
{showRecentsSeparator ? : null}
-
) : null}
-
+
{node.type === "branch" ? (
) : null}
-
+
);
})}
diff --git a/surfsense_web/components/new-chat/prompt-picker.tsx b/surfsense_web/components/new-chat/prompt-picker.tsx
index 986a5d608..65bf0a889 100644
--- a/surfsense_web/components/new-chat/prompt-picker.tsx
+++ b/surfsense_web/components/new-chat/prompt-picker.tsx
@@ -129,7 +129,9 @@ export const PromptPicker = forwardRef(funct
{isLoading ? (
) : isError ? (
- Failed to load prompts
+
+ Failed to load prompts
+
) : filtered.length === 0 ? (
No matching prompts
) : (
diff --git a/surfsense_web/components/settings/general-settings-manager.tsx b/surfsense_web/components/settings/general-settings-manager.tsx
index 23398ad4d..a308acfad 100644
--- a/surfsense_web/components/settings/general-settings-manager.tsx
+++ b/surfsense_web/components/settings/general-settings-manager.tsx
@@ -12,9 +12,9 @@ import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
+import { BACKEND_URL } from "@/lib/env-config";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { Spinner } from "../ui/spinner";
-import { BACKEND_URL } from "@/lib/env-config";
interface GeneralSettingsManagerProps {
searchSpaceId: number;
diff --git a/surfsense_web/components/settings/prompt-config-manager.tsx b/surfsense_web/components/settings/prompt-config-manager.tsx
index 71cfcb971..997749a2a 100644
--- a/surfsense_web/components/settings/prompt-config-manager.tsx
+++ b/surfsense_web/components/settings/prompt-config-manager.tsx
@@ -20,10 +20,7 @@ interface PromptConfigManagerProps {
}
export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps) {
- const {
- data: searchSpace,
- isLoading: loading,
- } = useQuery({
+ const { data: searchSpace, isLoading: loading } = useQuery({
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()),
queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }),
enabled: !!searchSpaceId,
@@ -56,8 +53,7 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
});
toast.success("System instructions saved successfully");
} catch (error: unknown) {
- const message =
- error instanceof Error ? error.message : "Failed to save system instructions";
+ const message = error instanceof Error ? error.message : "Failed to save system instructions";
console.error("Error saving system instructions:", error);
toast.error(message);
}
diff --git a/surfsense_web/components/tool-ui/generate-podcast.tsx b/surfsense_web/components/tool-ui/generate-podcast.tsx
index 866c0082d..2a62785e8 100644
--- a/surfsense_web/components/tool-ui/generate-podcast.tsx
+++ b/surfsense_web/components/tool-ui/generate-podcast.tsx
@@ -16,6 +16,7 @@ import { baseApiService } from "@/lib/apis/base-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
import { clearActivePodcastTaskId, setActivePodcastTaskId } from "@/lib/chat/podcast-state";
import { BACKEND_URL } from "@/lib/env-config";
+
/**
* Zod schemas for runtime validation
*/
@@ -193,10 +194,10 @@ function PodcastPlayer({
} else {
// Authenticated view - fetch audio and details in parallel
const [audioResponse, details] = await Promise.all([
- authenticatedFetch(
- `${BACKEND_URL}/api/v1/podcasts/${podcastId}/audio`,
- { method: "GET", signal: controller.signal }
- ),
+ authenticatedFetch(`${BACKEND_URL}/api/v1/podcasts/${podcastId}/audio`, {
+ method: "GET",
+ signal: controller.signal,
+ }),
baseApiService.get(`/api/v1/podcasts/${podcastId}`),
]);
diff --git a/surfsense_web/components/tool-ui/video-presentation/generate-video-presentation.tsx b/surfsense_web/components/tool-ui/video-presentation/generate-video-presentation.tsx
index 00f9db23f..1db8dabb0 100644
--- a/surfsense_web/components/tool-ui/video-presentation/generate-video-presentation.tsx
+++ b/surfsense_web/components/tool-ui/video-presentation/generate-video-presentation.tsx
@@ -10,6 +10,7 @@ import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { baseApiService } from "@/lib/apis/base-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
+import { BACKEND_URL } from "@/lib/env-config";
import { compileCheck, compileToComponent } from "@/lib/remotion/compile-check";
import { FPS } from "@/lib/remotion/constants";
import {
@@ -19,7 +20,6 @@ import {
type CompiledSlide,
} from "./combined-player";
import { getPptxExportErrorToast, getVideoDownloadErrorToast } from "./errors";
-import { BACKEND_URL } from "@/lib/env-config";
const GenerateVideoPresentationArgsSchema = z.object({
source_content: z.string(),
diff --git a/surfsense_web/hooks/use-search-source-connectors.ts b/surfsense_web/hooks/use-search-source-connectors.ts
index bc1ec49b5..ad0db3de6 100644
--- a/surfsense_web/hooks/use-search-source-connectors.ts
+++ b/surfsense_web/hooks/use-search-source-connectors.ts
@@ -107,9 +107,7 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?:
setError(null);
// Build URL with optional search_space_id query parameter
- const url = new URL(
- `${BACKEND_URL}/api/v1/search-source-connectors`
- );
+ const url = new URL(`${BACKEND_URL}/api/v1/search-source-connectors`);
if (spaceId !== undefined) {
url.searchParams.append("search_space_id", spaceId.toString());
}
@@ -169,9 +167,7 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?:
) => {
try {
// Add search_space_id as a query parameter
- const url = new URL(
- `${BACKEND_URL}/api/v1/search-source-connectors`
- );
+ const url = new URL(`${BACKEND_URL}/api/v1/search-source-connectors`);
url.searchParams.append("search_space_id", spaceId.toString());
const response = await authenticatedFetch(url.toString(), {
@@ -283,9 +279,7 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?:
}
const response = await authenticatedFetch(
- `${
- BACKEND_URL
- }/api/v1/search-source-connectors/${connectorId}/index?${params.toString()}`,
+ `${BACKEND_URL}/api/v1/search-source-connectors/${connectorId}/index?${params.toString()}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
diff --git a/surfsense_web/lib/auth-utils.ts b/surfsense_web/lib/auth-utils.ts
index 645a6d1ba..b7dab7717 100644
--- a/surfsense_web/lib/auth-utils.ts
+++ b/surfsense_web/lib/auth-utils.ts
@@ -2,6 +2,7 @@
* Authentication utilities for handling token expiration and redirects
*/
import { BACKEND_URL } from "@/lib/env-config";
+
const REDIRECT_PATH_KEY = "surfsense_redirect_path";
const BEARER_TOKEN_KEY = "surfsense_bearer_token";
const REFRESH_TOKEN_KEY = "surfsense_refresh_token";
diff --git a/surfsense_web/lib/posthog/events.ts b/surfsense_web/lib/posthog/events.ts
index 687d589f9..a584f9b6f 100644
--- a/surfsense_web/lib/posthog/events.ts
+++ b/surfsense_web/lib/posthog/events.ts
@@ -1,6 +1,6 @@
+import type { ChatErrorKind, ChatErrorSeverity, ChatFlow } from "@/lib/chat/chat-error-classifier";
import type { ConnectorTelemetryMeta } from "@/lib/connector-telemetry";
import { getConnectorTelemetryMeta } from "@/lib/connector-telemetry";
-import type { ChatErrorKind, ChatErrorSeverity, ChatFlow } from "@/lib/chat/chat-error-classifier";
/**
* PostHog Analytics Event Definitions
From c601a9b102d491f7a4b7125ca0df80922289deca Mon Sep 17 00:00:00 2001
From: "DESKTOP-RTLN3BA\\$punk"
Date: Thu, 28 May 2026 19:22:54 -0700
Subject: [PATCH 2/6] fix: biome errs
---
surfsense_web/components/ads/ad-unit.tsx | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/surfsense_web/components/ads/ad-unit.tsx b/surfsense_web/components/ads/ad-unit.tsx
index 5f5860607..cc5848a00 100644
--- a/surfsense_web/components/ads/ad-unit.tsx
+++ b/surfsense_web/components/ads/ad-unit.tsx
@@ -52,7 +52,8 @@ export function AdUnit({
// sets data-adsbygoogle-status="done" once it has filled a slot.
if (el.getAttribute("data-adsbygoogle-status")) return;
try {
- (window.adsbygoogle = window.adsbygoogle || []).push({});
+ window.adsbygoogle = window.adsbygoogle || [];
+ window.adsbygoogle.push({});
} catch {
// AdSense throws if pushed before the script has loaded or on
// duplicate pushes. The script processes pending pushes when it
From d013617bf60e5528a384386505d31fe557be0a93 Mon Sep 17 00:00:00 2001
From: "DESKTOP-RTLN3BA\\$punk"
Date: Thu, 28 May 2026 21:26:32 -0700
Subject: [PATCH 3/6] feat(automations): added UI and improved mentions
- Added support for @-mentions in agent tasks, allowing users to reference documents, folders, and connectors directly in their queries.
- Updated `run_agent_task` to resolve mentions and include them in the context passed to the agent.
- Introduced new parameters in `AgentTaskActionParams` for handling mentioned document and connector IDs.
- Refactored the automation edit and new components to utilize the new `AutomationBuilderForm` for a more streamlined user experience.
- Removed deprecated JSON forms to simplify the automation creation process.
---
.../automations/actions/agent_task/factory.py | 5 +
.../automations/actions/agent_task/invoke.py | 132 ++++-
.../automations/actions/agent_task/params.py | 31 ++
.../edit/automation-edit-content.tsx | 10 +-
.../edit/components/automation-edit-form.tsx | 118 -----
.../components/automation-edit-header.tsx | 31 ++
.../components/builder/advanced-section.tsx | 129 +++++
.../builder/automation-builder-form.tsx | 459 ++++++++++++++++++
.../components/builder/basics-section.tsx | 42 ++
.../components/builder/builder-summary.tsx | 96 ++++
.../components/builder/form-field.tsx | 42 ++
.../components/builder/json-mode-panel.tsx | 51 ++
.../components/builder/mention-task-input.tsx | 258 ++++++++++
.../components/builder/schedule-section.tsx | 275 +++++++++++
.../components/builder/task-item.tsx | 136 ++++++
.../components/builder/task-list.tsx | 65 +++
.../components/builder/timezone-combobox.tsx | 71 +++
.../components/builder/unattended-toggle.tsx | 47 ++
.../new/automation-new-content.tsx | 12 +-
.../new/components/automation-json-form.tsx | 98 ----
.../new/components/automation-new-header.tsx | 7 +-
.../new-chat/document-mention-picker.tsx | 24 +-
.../lib/automations/builder-schema.ts | 456 +++++++++++++++++
.../lib/automations/default-template.ts | 44 --
.../lib/automations/schedule-builder.ts | 132 +++++
25 files changed, 2490 insertions(+), 281 deletions(-)
delete mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-form.tsx
create mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-header.tsx
create mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/advanced-section.tsx
create mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-builder-form.tsx
create mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/basics-section.tsx
create mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/builder-summary.tsx
create mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/form-field.tsx
create mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/json-mode-panel.tsx
create mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/mention-task-input.tsx
create mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/schedule-section.tsx
create mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/task-item.tsx
create mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/task-list.tsx
create mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/timezone-combobox.tsx
create mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/unattended-toggle.tsx
delete mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/new/components/automation-json-form.tsx
create mode 100644 surfsense_web/lib/automations/builder-schema.ts
delete mode 100644 surfsense_web/lib/automations/default-template.ts
create mode 100644 surfsense_web/lib/automations/schedule-builder.ts
diff --git a/surfsense_backend/app/automations/actions/agent_task/factory.py b/surfsense_backend/app/automations/actions/agent_task/factory.py
index 18a408e13..dec75dce8 100644
--- a/surfsense_backend/app/automations/actions/agent_task/factory.py
+++ b/surfsense_backend/app/automations/actions/agent_task/factory.py
@@ -18,6 +18,11 @@ def build_handler(ctx: ActionContext) -> ActionHandler:
ctx=ctx,
query=validated.query,
auto_approve_all=validated.auto_approve_all,
+ mentioned_document_ids=validated.mentioned_document_ids,
+ mentioned_folder_ids=validated.mentioned_folder_ids,
+ mentioned_connector_ids=validated.mentioned_connector_ids,
+ mentioned_connectors=validated.mentioned_connectors,
+ mentioned_documents=validated.mentioned_documents,
)
return handle
diff --git a/surfsense_backend/app/automations/actions/agent_task/invoke.py b/surfsense_backend/app/automations/actions/agent_task/invoke.py
index 6cc92b232..fa02d263f 100644
--- a/surfsense_backend/app/automations/actions/agent_task/invoke.py
+++ b/surfsense_backend/app/automations/actions/agent_task/invoke.py
@@ -8,9 +8,13 @@ from typing import Any
from langchain_core.messages import HumanMessage
from langgraph.types import Command
+from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.multi_agent_chat import create_multi_agent_chat_deep_agent
+from app.agents.new_chat.context import SurfSenseContextSchema
+from app.agents.new_chat.mention_resolver import resolve_mentions, substitute_in_text
from app.db import ChatVisibility, async_session_maker
+from app.schemas.new_chat import MentionedDocumentInfo
from ..types import ActionContext
from .auto_decide import build_auto_decisions
@@ -22,17 +26,118 @@ from .finalize import extract_final_assistant_message
_MAX_RESUMES = 50
+def _build_connector_block(connectors: list[dict[str, Any]]) -> str | None:
+ """Render the ```` context block (same shape as chat).
+
+ Mirrors ``stream_new_chat`` so the agent gets the exact connector accounts
+ the user picked. Returns ``None`` when nothing renders.
+ """
+ lines: list[str] = []
+ for connector in connectors:
+ connector_id = connector.get("id")
+ connector_type = connector.get("connector_type") or connector.get(
+ "document_type"
+ )
+ account_name = connector.get("account_name") or connector.get("title")
+ if connector_id is None or connector_type is None:
+ continue
+ lines.append(
+ f' - connector_id={connector_id}, connector_type="{connector_type}", '
+ f'account_name="{account_name or ""}"'
+ )
+ if not lines:
+ return None
+ return (
+ "\n"
+ "The user selected these exact connector accounts with @. "
+ "These entries are selection metadata, not retrieved connector content. "
+ "When a connector-backed tool needs an account, use the matching "
+ "connector_id from this list if the tool supports connector_id:\n"
+ + "\n".join(lines)
+ + "\n "
+ )
+
+
+async def _resolve_mention_context(
+ session: AsyncSession,
+ *,
+ search_space_id: int,
+ query: str,
+ mentioned_document_ids: list[int] | None,
+ mentioned_folder_ids: list[int] | None,
+ mentioned_connector_ids: list[int] | None,
+ mentioned_connectors: list[MentionedDocumentInfo] | None,
+ mentioned_documents: list[MentionedDocumentInfo] | None,
+) -> tuple[str, SurfSenseContextSchema | None]:
+ """Resolve @-mentions into a rewritten query + per-invocation context.
+
+ Automation always runs in cloud filesystem mode, so we mirror the chat
+ ``new_chat`` flow: substitute ``@title`` tokens with canonical
+ ``/documents/...`` paths, prepend a ```` block, and
+ build a ``SurfSenseContextSchema`` that ``KnowledgePriorityMiddleware``
+ reads via ``runtime.context``. Returns ``(query, None)`` unchanged when
+ there are no mentions.
+ """
+ has_mentions = bool(
+ mentioned_document_ids
+ or mentioned_folder_ids
+ or mentioned_connector_ids
+ or mentioned_connectors
+ or mentioned_documents
+ )
+ if not has_mentions:
+ return query, None
+
+ resolved = await resolve_mentions(
+ session,
+ search_space_id=search_space_id,
+ mentioned_documents=mentioned_documents,
+ mentioned_document_ids=mentioned_document_ids,
+ mentioned_folder_ids=mentioned_folder_ids,
+ )
+ agent_query = substitute_in_text(query, resolved.token_to_path)
+
+ # ``SurfSenseContextSchema.mentioned_connectors`` is typed ``list[dict]`` and
+ # the connector block reads dicts, so dump the pydantic chips once.
+ connector_dicts = [c.model_dump() for c in (mentioned_connectors or [])]
+ connector_block = _build_connector_block(connector_dicts)
+ if connector_block:
+ agent_query = f"{connector_block}\n\n{agent_query} "
+
+ runtime_context = SurfSenseContextSchema(
+ search_space_id=search_space_id,
+ mentioned_document_ids=list(
+ resolved.mentioned_document_ids or (mentioned_document_ids or [])
+ ),
+ mentioned_folder_ids=list(
+ resolved.mentioned_folder_ids or (mentioned_folder_ids or [])
+ ),
+ mentioned_connector_ids=list(mentioned_connector_ids or []),
+ mentioned_connectors=connector_dicts,
+ )
+ return agent_query, runtime_context
+
+
async def run_agent_task(
*,
ctx: ActionContext,
query: str,
auto_approve_all: bool,
+ mentioned_document_ids: list[int] | None = None,
+ mentioned_folder_ids: list[int] | None = None,
+ mentioned_connector_ids: list[int] | None = None,
+ mentioned_connectors: list[MentionedDocumentInfo] | None = None,
+ mentioned_documents: list[MentionedDocumentInfo] | None = None,
) -> dict[str, Any]:
"""Invoke multi_agent_chat for one rendered query and return its outcome.
Opens its own DB session so the executor's bookkeeping session isn't tied
up for the entire invocation. The LangGraph ``thread_id`` (a fresh UUID)
is returned as ``agent_session_id`` for later inspection.
+
+ @-mentions (files / folders / connectors) chosen in the task input are
+ resolved the same way the chat flow does and forwarded to the agent via the
+ per-invocation ``context`` so they actually scope retrieval.
"""
agent_session_id = str(uuid.uuid4())
user_id = str(ctx.creator_user_id) if ctx.creator_user_id else None
@@ -55,12 +160,24 @@ async def run_agent_task(
agent_config=deps.agent_config,
firecrawl_api_key=deps.firecrawl_api_key,
thread_visibility=ChatVisibility.PRIVATE,
+ mentioned_document_ids=mentioned_document_ids,
+ )
+
+ agent_query, runtime_context = await _resolve_mention_context(
+ agent_session,
+ search_space_id=ctx.search_space_id,
+ query=query,
+ mentioned_document_ids=mentioned_document_ids,
+ mentioned_folder_ids=mentioned_folder_ids,
+ mentioned_connector_ids=mentioned_connector_ids,
+ mentioned_connectors=mentioned_connectors,
+ mentioned_documents=mentioned_documents,
)
request_id = f"automation:{ctx.run_id}:{ctx.step_id}"
turn_id = f"{request_id}:{int(time.time() * 1000)}"
input_state: dict[str, Any] = {
- "messages": [HumanMessage(content=query)],
+ "messages": [HumanMessage(content=agent_query)],
"search_space_id": ctx.search_space_id,
"request_id": request_id,
"turn_id": turn_id,
@@ -73,8 +190,17 @@ async def run_agent_task(
},
"recursion_limit": 10_000,
}
+ if runtime_context is not None:
+ runtime_context.request_id = request_id
+ runtime_context.turn_id = turn_id
- result = await agent.ainvoke(input_state, config=config)
+ # The compiled graph declares ``context_schema=SurfSenseContextSchema``;
+ # mentions only reach ``KnowledgePriorityMiddleware`` via ``context=``.
+ invoke_kwargs: dict[str, Any] = {"config": config}
+ if runtime_context is not None:
+ invoke_kwargs["context"] = runtime_context
+
+ result = await agent.ainvoke(input_state, **invoke_kwargs)
resumes = 0
while True:
@@ -87,7 +213,7 @@ async def run_agent_task(
)
lg_resume_map, routed = build_auto_decisions(state, decision)
config["configurable"]["surfsense_resume_value"] = routed
- result = await agent.ainvoke(Command(resume=lg_resume_map), config=config)
+ result = await agent.ainvoke(Command(resume=lg_resume_map), **invoke_kwargs)
resumes += 1
return {
diff --git a/surfsense_backend/app/automations/actions/agent_task/params.py b/surfsense_backend/app/automations/actions/agent_task/params.py
index b0e99a78b..ad6f35edb 100644
--- a/surfsense_backend/app/automations/actions/agent_task/params.py
+++ b/surfsense_backend/app/automations/actions/agent_task/params.py
@@ -4,6 +4,8 @@ from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field
+from app.schemas.new_chat import MentionedDocumentInfo
+
class AgentTaskActionParams(BaseModel):
"""Run a multi_agent_chat turn from an automation step."""
@@ -19,3 +21,32 @@ class AgentTaskActionParams(BaseModel):
default=False,
description="If true, every HITL approval is auto-approved; otherwise rejected.",
)
+
+ # @-mention references chosen in the task input. Mirror the ``new_chat``
+ # request fields (minus SurfSense product docs) so the run can scope
+ # retrieval to the user's selected files / folders / connectors. All
+ # optional and additive; a task with no mentions behaves as before.
+ mentioned_document_ids: list[int] | None = Field(
+ default=None,
+ description="Knowledge-base document IDs the task references with @.",
+ )
+ mentioned_folder_ids: list[int] | None = Field(
+ default=None,
+ description="Knowledge-base folder IDs the task references with @.",
+ )
+ mentioned_connector_ids: list[int] | None = Field(
+ default=None,
+ description="Concrete connector account IDs the task references with @.",
+ )
+ mentioned_connectors: list[MentionedDocumentInfo] | None = Field(
+ default=None,
+ description="Display/context metadata for the @-mentioned connector accounts.",
+ )
+ mentioned_documents: list[MentionedDocumentInfo] | None = Field(
+ default=None,
+ description=(
+ "Chip metadata (id, title, kind, ...) for every @-mention so the "
+ "run can resolve titles to virtual paths and substitute them in "
+ "the query."
+ ),
+ )
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/automation-edit-content.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/automation-edit-content.tsx
index 6504af5a4..2c9db217d 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/automation-edit-content.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/automation-edit-content.tsx
@@ -1,10 +1,11 @@
"use client";
import { ShieldAlert } from "lucide-react";
import { useAutomation } from "@/hooks/use-automation";
+import { AutomationBuilderForm } from "../../components/builder/automation-builder-form";
import { useAutomationPermissions } from "../../hooks/use-automation-permissions";
import { AutomationDetailLoading } from "../components/automation-detail-loading";
import { AutomationNotFound } from "../components/automation-not-found";
-import { AutomationEditForm } from "./components/automation-edit-form";
+import { AutomationEditHeader } from "./components/automation-edit-header";
interface AutomationEditContentProps {
searchSpaceId: number;
@@ -49,5 +50,10 @@ export function AutomationEditContent({ searchSpaceId, automationId }: Automatio
return ;
}
- return ;
+ return (
+ <>
+
+
+ >
+ );
}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-form.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-form.tsx
deleted file mode 100644
index 9b950608e..000000000
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-form.tsx
+++ /dev/null
@@ -1,118 +0,0 @@
-"use client";
-import { useAtomValue } from "jotai";
-import { AlertCircle, ArrowLeft, Save } from "lucide-react";
-import Link from "next/link";
-import { useRouter } from "next/navigation";
-import { useState } from "react";
-import { updateAutomationMutationAtom } from "@/atoms/automations/automations-mutation.atoms";
-import { JsonView } from "@/components/json-view";
-import { Button } from "@/components/ui/button";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Spinner } from "@/components/ui/spinner";
-import { type Automation, automationUpdateRequest } from "@/contracts/types/automation.types";
-
-interface AutomationEditFormProps {
- automation: Automation;
- searchSpaceId: number;
-}
-
-/**
- * Edit-existing-automation form. Surfaces the four mutable fields
- * (name, description, status, definition) as one editable JSON tree;
- * triggers stay on the detail page where they have their own management
- * UI. Validates with the same Zod schema the API expects, then PATCHes
- * the changed shape back.
- */
-export function AutomationEditForm({ automation, searchSpaceId }: AutomationEditFormProps) {
- const router = useRouter();
- const { mutateAsync: updateAutomation, isPending } = useAtomValue(updateAutomationMutationAtom);
- const detailHref = `/dashboard/${searchSpaceId}/automations/${automation.id}`;
-
- const [value, setValue] = useState(() => ({
- name: automation.name,
- description: automation.description ?? null,
- status: automation.status,
- definition: automation.definition,
- }));
- const [issues, setIssues] = useState([]);
-
- async function handleSave() {
- setIssues([]);
- const result = automationUpdateRequest.safeParse(value);
- if (!result.success) {
- setIssues(
- result.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
- );
- return;
- }
- try {
- await updateAutomation({ automationId: automation.id, patch: result.data });
- router.push(detailHref);
- } catch (err) {
- setIssues([(err as Error).message ?? "Update failed"]);
- }
- }
-
- return (
- <>
-
-
-
-
- Back to automation
-
-
-
-
- Edit automation
-
-
{automation.name}
-
-
-
-
-
- Definition
-
-
-
- setValue(next as typeof value)}
- collapsed={false}
- />
-
-
- {issues.length > 0 && (
-
-
-
- {issues.length === 1 ? "1 issue" : `${issues.length} issues`}
-
-
- {issues.map((issue) => (
- {issue}
- ))}
-
-
- )}
-
-
-
- Cancel
-
-
- {isPending ? (
-
- ) : (
-
- )}
- Save changes
-
-
-
-
- >
- );
-}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-header.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-header.tsx
new file mode 100644
index 000000000..6b2a31822
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-header.tsx
@@ -0,0 +1,31 @@
+"use client";
+import { ArrowLeft } from "lucide-react";
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import type { Automation } from "@/contracts/types/automation.types";
+
+interface AutomationEditHeaderProps {
+ automation: Automation;
+ searchSpaceId: number;
+}
+
+export function AutomationEditHeader({ automation, searchSpaceId }: AutomationEditHeaderProps) {
+ const detailHref = `/dashboard/${searchSpaceId}/automations/${automation.id}`;
+
+ return (
+
+
+
+
+ Back to automation
+
+
+
+
+ Edit automation
+
+
{automation.name}
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/advanced-section.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/advanced-section.tsx
new file mode 100644
index 000000000..740f199af
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/advanced-section.tsx
@@ -0,0 +1,129 @@
+"use client";
+import { useState } from "react";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import type { BuilderExecution } from "@/lib/automations/builder-schema";
+import { Field } from "./form-field";
+
+interface AdvancedSectionProps {
+ execution: BuilderExecution;
+ tags: string[];
+ onExecutionChange: (patch: Partial) => void;
+ onTagsChange: (tags: string[]) => void;
+}
+
+const BACKOFF_OPTIONS: ReadonlyArray<{ value: BuilderExecution["retryBackoff"]; label: string }> = [
+ { value: "exponential", label: "Exponential" },
+ { value: "linear", label: "Linear" },
+ { value: "none", label: "None" },
+];
+
+const CONCURRENCY_OPTIONS: ReadonlyArray<{
+ value: BuilderExecution["concurrency"];
+ label: string;
+}> = [
+ { value: "drop_if_running", label: "Skip if already running" },
+ { value: "queue", label: "Queue the next run" },
+ { value: "always", label: "Always run" },
+];
+
+function clampInt(raw: string, min: number, fallback: number): number {
+ const value = Number.parseInt(raw, 10);
+ if (Number.isNaN(value)) return fallback;
+ return Math.max(min, value);
+}
+
+export function AdvancedSection({
+ execution,
+ tags,
+ onExecutionChange,
+ onTagsChange,
+}: AdvancedSectionProps) {
+ const [tagsText, setTagsText] = useState(tags.join(", "));
+
+ function commitTags(text: string) {
+ const next = text
+ .split(",")
+ .map((tag) => tag.trim())
+ .filter(Boolean);
+ onTagsChange(next);
+ }
+
+ return (
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-builder-form.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-builder-form.tsx
new file mode 100644
index 000000000..1fd37cd3d
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-builder-form.tsx
@@ -0,0 +1,459 @@
+"use client";
+import { useAtomValue } from "jotai";
+import { Code2, LayoutList, Save } from "lucide-react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useMemo, useState } from "react";
+import type { z } from "zod";
+import {
+ addTriggerMutationAtom,
+ createAutomationMutationAtom,
+ removeTriggerMutationAtom,
+ updateAutomationMutationAtom,
+ updateTriggerMutationAtom,
+} from "@/atoms/automations/automations-mutation.atoms";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Spinner } from "@/components/ui/spinner";
+import {
+ type Automation,
+ automationCreateRequest,
+ automationUpdateRequest,
+} from "@/contracts/types/automation.types";
+import {
+ type BuilderForm,
+ buildCreatePayload,
+ builderFormSchema,
+ buildScheduleTrigger,
+ buildUpdatePayload,
+ createEmptyForm,
+ formFromAutomation,
+ type HydratableTrigger,
+ hydrateForm,
+} from "@/lib/automations/builder-schema";
+import { cn } from "@/lib/utils";
+import { AdvancedSection } from "./advanced-section";
+import { BasicsSection } from "./basics-section";
+import { BuilderSummary } from "./builder-summary";
+import { JsonModePanel } from "./json-mode-panel";
+import { ScheduleSection } from "./schedule-section";
+import { TaskList } from "./task-list";
+import { UnattendedToggle } from "./unattended-toggle";
+
+interface AutomationBuilderFormProps {
+ mode: "create" | "edit";
+ searchSpaceId: number;
+ /** Required in edit mode; seeds the form and trigger reconciliation. */
+ automation?: Automation;
+}
+
+type Mode = "form" | "json";
+
+function mapFormErrors(error: z.ZodError): Record {
+ const out: Record = {};
+ for (const issue of error.issues) {
+ const path = issue.path;
+ let key: string;
+ if (path[0] === "tasks" && typeof path[1] === "number") key = `tasks.${path[1]}.query`;
+ else if (path[0] === "schedule") key = "schedule";
+ else key = String(path[0] ?? "_root");
+ if (!out[key]) out[key] = issue.message;
+ }
+ return out;
+}
+
+export function AutomationBuilderForm({
+ mode,
+ searchSpaceId,
+ automation,
+}: AutomationBuilderFormProps) {
+ const router = useRouter();
+ const { mutateAsync: createAutomation } = useAtomValue(createAutomationMutationAtom);
+ const { mutateAsync: updateAutomation } = useAtomValue(updateAutomationMutationAtom);
+ const { mutateAsync: addTrigger } = useAtomValue(addTriggerMutationAtom);
+ const { mutateAsync: updateTrigger } = useAtomValue(updateTriggerMutationAtom);
+ const { mutateAsync: removeTrigger } = useAtomValue(removeTriggerMutationAtom);
+
+ // Initial state: create starts empty in form mode; edit hydrates, falling
+ // back to JSON mode when the definition can't be represented in the form.
+ const initial = useMemo(() => {
+ if (mode === "edit" && automation) {
+ const result = formFromAutomation(automation);
+ if (result.formable) {
+ return { mode: "form" as Mode, form: result.form, notice: undefined };
+ }
+ return {
+ mode: "json" as Mode,
+ form: createEmptyForm(),
+ notice: `This automation ${result.reason}, which the form can't show. Edit it as JSON below.`,
+ };
+ }
+ return { mode: "form" as Mode, form: createEmptyForm(), notice: undefined };
+ }, [mode, automation]);
+
+ const [activeMode, setActiveMode] = useState(initial.mode);
+ const [form, setForm] = useState(initial.form);
+ const [errors, setErrors] = useState>({});
+ const [rootError, setRootError] = useState(null);
+
+ const [jsonValue, setJsonValue] = useState>(() =>
+ initial.mode === "json" ? jsonFromAutomation(automation) : {}
+ );
+ const [jsonIssues, setJsonIssues] = useState([]);
+ const [jsonNotice, setJsonNotice] = useState(initial.notice);
+
+ const [submitting, setSubmitting] = useState(false);
+
+ const cancelHref =
+ mode === "edit" && automation
+ ? `/dashboard/${searchSpaceId}/automations/${automation.id}`
+ : `/dashboard/${searchSpaceId}/automations`;
+
+ function patchForm(patch: Partial) {
+ setForm((prev) => ({ ...prev, ...patch }));
+ }
+
+ function jsonFromCurrentForm(): Record {
+ if (mode === "edit" && automation) {
+ return { ...buildUpdatePayload(form), status: automation.status };
+ }
+ const { search_space_id: _ignored, ...rest } = buildCreatePayload(form, searchSpaceId);
+ return rest;
+ }
+
+ function switchToJson() {
+ setJsonValue(jsonFromCurrentForm());
+ setJsonIssues([]);
+ setJsonNotice(undefined);
+ setActiveMode("json");
+ }
+
+ function switchToForm() {
+ const result = tryJsonToForm();
+ if (result.ok) {
+ setForm(result.form);
+ setErrors({});
+ setRootError(null);
+ setActiveMode("form");
+ return;
+ }
+ setJsonIssues(result.issues);
+ setJsonNotice(result.notice);
+ }
+
+ function tryJsonToForm():
+ | { ok: true; form: BuilderForm }
+ | { ok: false; issues: string[]; notice?: string } {
+ // Read the raw tree defensively rather than strict-validating: an
+ // incomplete JSON edit should still round-trip into the form, where the
+ // form's own validation enforces completeness on submit.
+ const definition = jsonValue.definition;
+ if (!definition || typeof definition !== "object") {
+ return { ok: false, issues: [], notice: "Add a definition before switching to the form." };
+ }
+
+ const name =
+ typeof jsonValue.name === "string"
+ ? jsonValue.name
+ : mode === "edit" && automation
+ ? automation.name
+ : "";
+ const description = typeof jsonValue.description === "string" ? jsonValue.description : null;
+ const triggers =
+ mode === "edit" && automation
+ ? (automation.triggers ?? [])
+ : extractTriggers(jsonValue.triggers);
+
+ const h = hydrateForm(name, description, definition, triggers);
+ return h.formable
+ ? { ok: true, form: h.form }
+ : { ok: false, issues: [], notice: `Can't show in the form: it ${h.reason}.` };
+ }
+
+ function validateForm(): Record | null {
+ const result = builderFormSchema.safeParse(form);
+ const next = result.success ? {} : mapFormErrors(result.error);
+
+ // The schedule model fields aren't deeply validated by the schema.
+ if (form.schedule?.mode === "preset") {
+ const m = form.schedule.model;
+ if (m.frequency === "weekly" && m.daysOfWeek.length === 0) {
+ next.schedule = "Pick at least one day for the weekly schedule";
+ }
+ } else if (form.schedule?.mode === "cron" && !form.schedule.cron.trim()) {
+ next.schedule = "Enter a schedule expression";
+ }
+
+ return Object.keys(next).length > 0 ? next : null;
+ }
+
+ async function reconcileTriggers(automationId: number) {
+ const desired = buildScheduleTrigger(form);
+ const existing = (automation?.triggers ?? [])[0];
+ if (!existing && desired) {
+ await addTrigger({ automationId, payload: desired });
+ } else if (existing && !desired) {
+ await removeTrigger({ automationId, triggerId: existing.id });
+ } else if (existing && desired) {
+ await updateTrigger({
+ automationId,
+ triggerId: existing.id,
+ patch: { params: desired.params, enabled: desired.enabled },
+ });
+ }
+ }
+
+ async function submitForm() {
+ setRootError(null);
+ const formErrors = validateForm();
+ if (formErrors) {
+ setErrors(formErrors);
+ return;
+ }
+ setErrors({});
+
+ setSubmitting(true);
+ try {
+ if (mode === "edit" && automation) {
+ const payload = buildUpdatePayload(form);
+ const parsed = automationUpdateRequest.safeParse(payload);
+ if (!parsed.success) {
+ setRootError(zodIssueList(parsed.error).join("; "));
+ return;
+ }
+ await updateAutomation({ automationId: automation.id, patch: parsed.data });
+ await reconcileTriggers(automation.id);
+ router.push(`/dashboard/${searchSpaceId}/automations/${automation.id}`);
+ } else {
+ const payload = buildCreatePayload(form, searchSpaceId);
+ const parsed = automationCreateRequest.safeParse(payload);
+ if (!parsed.success) {
+ setRootError(zodIssueList(parsed.error).join("; "));
+ return;
+ }
+ const created = await createAutomation(parsed.data);
+ router.push(`/dashboard/${searchSpaceId}/automations/${created.id}`);
+ }
+ } catch (err) {
+ setRootError((err as Error).message ?? "Submit failed");
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ async function submitJson() {
+ setJsonIssues([]);
+ setSubmitting(true);
+ try {
+ if (mode === "edit" && automation) {
+ const parsed = automationUpdateRequest.safeParse(jsonValue);
+ if (!parsed.success) {
+ setJsonIssues(zodIssueList(parsed.error));
+ return;
+ }
+ await updateAutomation({ automationId: automation.id, patch: parsed.data });
+ router.push(`/dashboard/${searchSpaceId}/automations/${automation.id}`);
+ } else {
+ const parsed = automationCreateRequest.safeParse({
+ ...jsonValue,
+ search_space_id: searchSpaceId,
+ });
+ if (!parsed.success) {
+ setJsonIssues(zodIssueList(parsed.error));
+ return;
+ }
+ const created = await createAutomation(parsed.data);
+ router.push(`/dashboard/${searchSpaceId}/automations/${created.id}`);
+ }
+ } catch (err) {
+ setJsonIssues([(err as Error).message ?? "Submit failed"]);
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ const submitLabel = mode === "edit" ? "Save changes" : "Create automation";
+
+ return (
+
+
+
+ (activeMode === "form" ? undefined : switchToForm())}
+ />
+ (activeMode === "json" ? undefined : switchToJson())}
+ />
+
+
+
+ {activeMode === "json" ? (
+
+
+
+
+
+ ) : (
+
+
+
+
+ Basics
+
+
+
+
+
+
+
+
+ Tasks
+
+
+ patchForm({ tasks })}
+ />
+ patchForm({ unattended })}
+ />
+
+
+
+
+
+ Schedule
+
+
+ patchForm({ schedule })}
+ onTimezoneChange={(timezone) => patchForm({ timezone })}
+ />
+
+
+
+
+
+ Settings
+
+
+
+ patchForm({ execution: { ...form.execution, ...patch } })
+ }
+ onTagsChange={(tags) => patchForm({ tags })}
+ />
+
+
+
+
+
+
+
+ Summary
+
+
+
+
+
+
+
+ )}
+
+ {rootError &&
{rootError}
}
+
+
+
+ Cancel
+
+ (activeMode === "json" ? submitJson() : submitForm())}
+ >
+ {submitting ? : }
+ {submitLabel}
+
+
+
+ );
+}
+
+function ModeButton({
+ active,
+ icon: Icon,
+ label,
+ onClick,
+}: {
+ active: boolean;
+ icon: typeof Code2;
+ label: string;
+ onClick: () => void;
+}) {
+ return (
+
+
+ {label}
+
+ );
+}
+
+function extractTriggers(raw: unknown): HydratableTrigger[] {
+ if (!Array.isArray(raw)) return [];
+ return raw.map((entry) => {
+ const obj = entry && typeof entry === "object" ? (entry as Record) : {};
+ return {
+ type: typeof obj.type === "string" ? obj.type : "",
+ params:
+ obj.params && typeof obj.params === "object" ? (obj.params as Record) : {},
+ };
+ });
+}
+
+function zodIssueList(error: z.ZodError): string[] {
+ return error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`);
+}
+
+function jsonFromAutomation(automation: Automation | undefined): Record {
+ if (!automation) return {};
+ return {
+ name: automation.name,
+ description: automation.description ?? null,
+ status: automation.status,
+ definition: automation.definition,
+ };
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/basics-section.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/basics-section.tsx
new file mode 100644
index 000000000..fdc9f4526
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/basics-section.tsx
@@ -0,0 +1,42 @@
+"use client";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Field } from "./form-field";
+
+interface BasicsSectionProps {
+ name: string;
+ description: string | null;
+ errors: Record;
+ onChange: (patch: { name?: string; description?: string | null }) => void;
+}
+
+export function BasicsSection({ name, description, errors, onChange }: BasicsSectionProps) {
+ return (
+
+
+ onChange({ name: e.target.value })}
+ />
+
+
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/builder-summary.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/builder-summary.tsx
new file mode 100644
index 000000000..21a77cb5f
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/builder-summary.tsx
@@ -0,0 +1,96 @@
+"use client";
+import { CalendarClock, CheckCircle2, ListOrdered, type LucideIcon, XCircle } from "lucide-react";
+import { type BuilderForm, scheduleToCron } from "@/lib/automations/builder-schema";
+import { describeCron } from "@/lib/automations/describe-cron";
+
+interface BuilderSummaryProps {
+ form: BuilderForm;
+}
+
+/**
+ * Live, read-only mirror of what will be created. Mirrors the layout of the
+ * chat ``AutomationDraftPreview`` so the two creation paths feel consistent.
+ */
+export function BuilderSummary({ form }: BuilderSummaryProps) {
+ const scheduleLabel = form.schedule
+ ? `${describeCron(scheduleToCron(form.schedule))} · ${form.timezone}`
+ : "No schedule — won't run automatically";
+
+ return (
+
+
+
{form.name.trim() || "Untitled automation"}
+ {form.description?.trim() && (
+
{form.description.trim()}
+ )}
+
+
+
+
+
+
+ {form.tasks.map((task, index) => (
+
+
+ {index + 1}
+
+
+
+ {task.query.trim() || (
+ No instructions yet
+ )}
+
+ {task.mentions.length > 0 && (
+
+ {task.mentions.map((mention) => (
+
+ @{mention.title}
+
+ ))}
+
+ )}
+
+
+ ))}
+
+
+
+
+ {form.unattended ? (
+
+ ) : (
+
+ )}
+ {form.unattended ? "Runs without approval prompts" : "Will reject approval prompts"}
+
+
+ );
+}
+
+function Section({
+ icon: Icon,
+ label,
+ children,
+}: {
+ icon: LucideIcon;
+ label: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+ {label}
+
+ {children}
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/form-field.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/form-field.tsx
new file mode 100644
index 000000000..222efd9c6
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/form-field.tsx
@@ -0,0 +1,42 @@
+"use client";
+import { AlertCircle } from "lucide-react";
+import type { ReactNode } from "react";
+import { Label } from "@/components/ui/label";
+import { cn } from "@/lib/utils";
+
+interface FieldProps {
+ label?: string;
+ htmlFor?: string;
+ hint?: string;
+ error?: string;
+ required?: boolean;
+ className?: string;
+ children: ReactNode;
+}
+
+/**
+ * Label + control + (hint | inline error) stack shared by every builder
+ * section. Keeps spacing and error styling consistent so individual sections
+ * stay focused on their inputs.
+ */
+export function Field({ label, htmlFor, hint, error, required, className, children }: FieldProps) {
+ return (
+
+ {label && (
+
+ {label}
+ {required && * }
+
+ )}
+ {children}
+ {error ? (
+
+
+ {error}
+
+ ) : hint ? (
+
{hint}
+ ) : null}
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/json-mode-panel.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/json-mode-panel.tsx
new file mode 100644
index 000000000..1f25f8a61
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/json-mode-panel.tsx
@@ -0,0 +1,51 @@
+"use client";
+import { AlertCircle } from "lucide-react";
+import { JsonView } from "@/components/json-view";
+
+interface JsonModePanelProps {
+ value: Record;
+ issues: string[];
+ notice?: string;
+ onChange: (next: Record) => void;
+}
+
+/**
+ * Raw-JSON escape hatch. Edits the same payload the form produces; the
+ * orchestrator validates it against the contract schema on submit. Shown when
+ * the user opts into "Edit as JSON" or when an existing definition uses
+ * features the form can't represent.
+ */
+export function JsonModePanel({ value, issues, notice, onChange }: JsonModePanelProps) {
+ return (
+
+ {notice && (
+
+ {notice}
+
+ )}
+
+
+ onChange(next as Record)}
+ collapsed={false}
+ />
+
+
+ {issues.length > 0 && (
+
+
+
+ {issues.length === 1 ? "1 issue" : `${issues.length} issues`}
+
+
+ {issues.map((issue) => (
+ {issue}
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/mention-task-input.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/mention-task-input.tsx
new file mode 100644
index 000000000..312454056
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/mention-task-input.tsx
@@ -0,0 +1,258 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import type { MentionedDocumentInfo } from "@/atoms/chat/mentioned-documents.atom";
+import {
+ InlineMentionEditor,
+ type InlineMentionEditorRef,
+ type MentionChipInput,
+ type MentionedDocument,
+ type SuggestionAnchorRect,
+ type SuggestionTriggerInfo,
+} from "@/components/assistant-ui/inline-mention-editor";
+import { ComposerSuggestionPopoverContent } from "@/components/new-chat/composer-suggestion-popup";
+import {
+ DocumentMentionPicker,
+ type DocumentMentionPickerRef,
+} from "@/components/new-chat/document-mention-picker";
+import { Popover, PopoverAnchor } from "@/components/ui/popover";
+import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
+import { cn } from "@/lib/utils";
+
+interface MentionTaskInputProps {
+ searchSpaceId: number;
+ value: string;
+ mentions: MentionedDocumentInfo[];
+ onChange: (text: string, mentions: MentionedDocumentInfo[]) => void;
+ placeholder?: string;
+ disabled?: boolean;
+}
+
+type AnchorPoint = { left: number; top: number };
+
+// Mirror of thread.tsx's getComposerSuggestionAnchorPoint -- kept local so the
+// chat composer stays untouched.
+function getAnchorPoint(rect: SuggestionAnchorRect | null): AnchorPoint | null {
+ if (!rect) return null;
+ return { left: rect.left, top: rect.bottom };
+}
+
+/** Project the editor's chip shape into the canonical mention info union. */
+function toMentionInfo(doc: MentionedDocument): MentionedDocumentInfo {
+ if (doc.kind === "connector") {
+ return {
+ id: doc.id,
+ title: doc.title,
+ kind: "connector",
+ connector_type: doc.connector_type ?? "UNKNOWN",
+ account_name: doc.account_name ?? doc.title,
+ };
+ }
+ if (doc.kind === "folder") {
+ return { id: doc.id, title: doc.title, kind: "folder" };
+ }
+ return {
+ id: doc.id,
+ title: doc.title,
+ document_type: doc.document_type ?? "UNKNOWN",
+ kind: "doc",
+ };
+}
+
+/** Project a mention info into the editor's chip-insertion shape. */
+function toChipInput(mention: MentionedDocumentInfo): MentionChipInput {
+ if (mention.kind === "connector") {
+ return {
+ id: mention.id,
+ title: mention.title,
+ kind: "connector",
+ connector_type: mention.connector_type,
+ account_name: mention.account_name,
+ };
+ }
+ if (mention.kind === "folder") {
+ return { id: mention.id, title: mention.title, kind: "folder" };
+ }
+ return {
+ id: mention.id,
+ title: mention.title,
+ kind: "doc",
+ document_type: mention.document_type,
+ };
+}
+
+function removeFirstToken(text: string, token: string): string {
+ const index = text.indexOf(token);
+ if (index === -1) return text;
+ return text.slice(0, index) + text.slice(index + token.length);
+}
+
+/**
+ * Task input that reuses the chat ``@`` mention experience -- the same
+ * ``InlineMentionEditor`` + ``DocumentMentionPicker`` as the composer, minus
+ * SurfSense product docs. The editor is the source of truth while mounted;
+ * ``onChange`` reports both the plain text (chips rendered as ``@Title``) and
+ * the structured mention list so the builder can persist IDs for the run.
+ */
+export function MentionTaskInput({
+ searchSpaceId,
+ value,
+ mentions,
+ onChange,
+ placeholder,
+ disabled,
+}: MentionTaskInputProps) {
+ const editorRef = useRef(null);
+ const pickerRef = useRef(null);
+
+ const [showPopover, setShowPopover] = useState(false);
+ const [mentionQuery, setMentionQuery] = useState("");
+ const [anchorPoint, setAnchorPoint] = useState(null);
+
+ // One-shot hydration of existing mentions into real chips. ``initialText``
+ // seeds the literal ``@Title`` text; here we strip those tokens and
+ // re-insert them as chips so the editor reports the structured docs (and
+ // editing can't silently drop the mention IDs). Position isn't preserved
+ // on re-hydration -- chips append after the remaining prose.
+ const didHydrateRef = useRef(false);
+ useEffect(() => {
+ if (didHydrateRef.current) return;
+ didHydrateRef.current = true;
+ if (mentions.length === 0) return;
+ const editor = editorRef.current;
+ if (!editor) return;
+
+ let baseText = value;
+ for (const mention of mentions) {
+ baseText = removeFirstToken(baseText, `@${mention.title}`);
+ }
+ baseText = baseText.replace(/[ \t]{2,}/g, " ").trim();
+ editor.setText(baseText);
+ for (const mention of mentions) {
+ editor.insertMentionChip(toChipInput(mention), { removeTriggerText: false });
+ }
+ }, [mentions, value]);
+
+ const closePopover = useCallback(() => {
+ setShowPopover(false);
+ setMentionQuery("");
+ setAnchorPoint(null);
+ }, []);
+
+ const handleEditorChange = useCallback(
+ (text: string, docs: MentionedDocument[]) => {
+ onChange(text, docs.map(toMentionInfo));
+ },
+ [onChange]
+ );
+
+ const handleMentionTrigger = useCallback((trigger: SuggestionTriggerInfo) => {
+ const point = getAnchorPoint(trigger.anchorRect);
+ if (!point) {
+ setShowPopover(false);
+ setMentionQuery("");
+ setAnchorPoint(null);
+ return;
+ }
+ setAnchorPoint((current) => current ?? point);
+ setShowPopover(true);
+ setMentionQuery(trigger.query);
+ }, []);
+
+ const handleMentionClose = useCallback(() => {
+ setShowPopover((open) => {
+ if (open) {
+ setMentionQuery("");
+ setAnchorPoint(null);
+ }
+ return false;
+ });
+ }, []);
+
+ const handlePopoverOpenChange = useCallback((open: boolean) => {
+ setShowPopover(open);
+ if (!open) {
+ setMentionQuery("");
+ setAnchorPoint(null);
+ }
+ }, []);
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (!showPopover) return;
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ pickerRef.current?.moveDown();
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ pickerRef.current?.moveUp();
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ pickerRef.current?.selectHighlighted();
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ if (pickerRef.current?.goBack()) return;
+ closePopover();
+ }
+ },
+ [showPopover, closePopover]
+ );
+
+ const handleSelection = useCallback(
+ (picked: MentionedDocumentInfo[]) => {
+ const editor = editorRef.current;
+ const existing = new Set(
+ (editor?.getMentionedDocuments() ?? []).map((doc) => getMentionDocKey(doc))
+ );
+ for (const mention of picked) {
+ const key = getMentionDocKey(mention);
+ if (existing.has(key)) continue;
+ editor?.insertMentionChip(toChipInput(mention));
+ existing.add(key);
+ }
+ closePopover();
+ },
+ [closePopover]
+ );
+
+ return (
+
+
+ {anchorPoint ? (
+ <>
+
+
+
+
+ >
+ ) : null}
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/schedule-section.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/schedule-section.tsx
new file mode 100644
index 000000000..401b4f5cb
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/schedule-section.tsx
@@ -0,0 +1,275 @@
+"use client";
+import { CalendarClock, CalendarOff, Plus, X } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { type BuilderSchedule, scheduleToCron } from "@/lib/automations/builder-schema";
+import { describeCron } from "@/lib/automations/describe-cron";
+import {
+ DEFAULT_SCHEDULE,
+ FREQUENCY_OPTIONS,
+ fromCron,
+ type ScheduleFrequency,
+ type ScheduleModel,
+ toCron,
+ WEEKDAY_OPTIONS,
+} from "@/lib/automations/schedule-builder";
+import { cn } from "@/lib/utils";
+import { Field } from "./form-field";
+import { TimezoneCombobox } from "./timezone-combobox";
+
+interface ScheduleSectionProps {
+ schedule: BuilderSchedule | null;
+ timezone: string;
+ errors: Record;
+ onScheduleChange: (schedule: BuilderSchedule | null) => void;
+ onTimezoneChange: (timezone: string) => void;
+}
+
+function pad(value: number): string {
+ return value.toString().padStart(2, "0");
+}
+
+export function ScheduleSection({
+ schedule,
+ timezone,
+ errors,
+ onScheduleChange,
+ onTimezoneChange,
+}: ScheduleSectionProps) {
+ if (schedule === null) {
+ return (
+
+
+
No schedule
+
+ This automation won't run automatically until you add one.
+
+
onScheduleChange({ mode: "preset", model: { ...DEFAULT_SCHEDULE } })}
+ >
+
+ Add a schedule
+
+
+ );
+ }
+
+ const cron = scheduleToCron(schedule);
+ const label = describeCron(cron);
+
+ return (
+
+
+
+
+ {label}
+ · {timezone}
+
+
onScheduleChange(null)}
+ >
+
+
+
+
+ {schedule.mode === "preset" ? (
+
onScheduleChange({ mode: "preset", model })}
+ onSwitchToCron={() => onScheduleChange({ mode: "cron", cron: toCron(schedule.model) })}
+ />
+ ) : (
+ onScheduleChange({ mode: "cron", cron: value })}
+ onSwitchToPreset={() =>
+ onScheduleChange({
+ mode: "preset",
+ model: fromCron(schedule.cron) ?? { ...DEFAULT_SCHEDULE },
+ })
+ }
+ />
+ )}
+
+
+
+
+
+ );
+}
+
+interface PresetEditorProps {
+ model: ScheduleModel;
+ onChange: (model: ScheduleModel) => void;
+ onSwitchToCron: () => void;
+}
+
+function PresetEditor({ model, onChange, onSwitchToCron }: PresetEditorProps) {
+ const weeklyNoDays = model.frequency === "weekly" && model.daysOfWeek.length === 0;
+
+ return (
+
+
+
+ onChange({ ...model, frequency: value as ScheduleFrequency })}
+ >
+
+
+
+
+ {FREQUENCY_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+ {model.frequency === "hourly" ? (
+
+ onChange({ ...model, minute: clampInt(e.target.value, 0, 59) })}
+ />
+
+ ) : (
+
+ {
+ const [h, m] = e.target.value.split(":");
+ onChange({
+ ...model,
+ hour: clampInt(h, 0, 23),
+ minute: clampInt(m, 0, 59),
+ });
+ }}
+ />
+
+ )}
+
+
+ {model.frequency === "weekly" && (
+
+
+ {WEEKDAY_OPTIONS.map((day) => {
+ const active = model.daysOfWeek.includes(day.value);
+ return (
+
+ onChange({ ...model, daysOfWeek: toggleDay(model.daysOfWeek, day.value) })
+ }
+ className={cn(
+ "rounded-md border px-2.5 py-1 text-xs font-medium transition-colors",
+ active
+ ? "border-primary bg-primary text-primary-foreground"
+ : "border-border/60 bg-background text-muted-foreground hover:bg-muted"
+ )}
+ >
+ {day.short}
+
+ );
+ })}
+
+
+ )}
+
+ {model.frequency === "monthly" && (
+
+ onChange({ ...model, dayOfMonth: clampInt(e.target.value, 1, 31) })}
+ className="w-24"
+ />
+
+ )}
+
+
+ Advanced: enter a schedule expression
+
+
+ );
+}
+
+interface CronEditorProps {
+ cron: string;
+ error?: string;
+ onChange: (cron: string) => void;
+ onSwitchToPreset: () => void;
+}
+
+function CronEditor({ cron, error, onChange, onSwitchToPreset }: CronEditorProps) {
+ const trimmed = cron.trim();
+ const label = trimmed ? describeCron(trimmed) : null;
+
+ return (
+
+
+ onChange(e.target.value)}
+ />
+
+ {label && label !== trimmed &&
Runs: {label}
}
+
+ Use the simple picker
+
+
+ );
+}
+
+function clampInt(raw: string, min: number, max: number): number {
+ const value = Number.parseInt(raw, 10);
+ if (Number.isNaN(value)) return min;
+ return Math.min(max, Math.max(min, value));
+}
+
+function toggleDay(days: number[], value: number): number[] {
+ return days.includes(value)
+ ? days.filter((day) => day !== value)
+ : [...days, value].sort((a, b) => a - b);
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/task-item.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/task-item.tsx
new file mode 100644
index 000000000..55b9ea406
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/task-item.tsx
@@ -0,0 +1,136 @@
+"use client";
+import { ChevronDown, ChevronUp, Trash2 } from "lucide-react";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@/components/ui/accordion";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import type { BuilderTask } from "@/lib/automations/builder-schema";
+import { Field } from "./form-field";
+import { MentionTaskInput } from "./mention-task-input";
+
+interface TaskItemProps {
+ index: number;
+ total: number;
+ task: BuilderTask;
+ searchSpaceId: number;
+ error?: string;
+ onChange: (patch: Partial) => void;
+ onMoveUp: () => void;
+ onMoveDown: () => void;
+ onRemove: () => void;
+}
+
+function parseOptionalInt(raw: string): number | null {
+ const trimmed = raw.trim();
+ if (trimmed === "") return null;
+ const value = Number.parseInt(trimmed, 10);
+ return Number.isNaN(value) ? null : value;
+}
+
+export function TaskItem({
+ index,
+ total,
+ task,
+ searchSpaceId,
+ error,
+ onChange,
+ onMoveUp,
+ onMoveDown,
+ onRemove,
+}: TaskItemProps) {
+ return (
+
+
+
+
+ {index + 1}
+
+ Task {index + 1}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ onChange({ query, mentions })}
+ />
+
+
+
+
+
+ Advanced
+
+
+
+
+ onChange({ maxRetries: parseOptionalInt(e.target.value) })}
+ />
+
+
+ onChange({ timeoutSeconds: parseOptionalInt(e.target.value) })}
+ />
+
+
+
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/task-list.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/task-list.tsx
new file mode 100644
index 000000000..41a53542f
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/task-list.tsx
@@ -0,0 +1,65 @@
+"use client";
+import { Plus } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { type BuilderTask, emptyTask } from "@/lib/automations/builder-schema";
+import { TaskItem } from "./task-item";
+
+interface TaskListProps {
+ tasks: BuilderTask[];
+ errors: Record;
+ searchSpaceId: number;
+ onChange: (tasks: BuilderTask[]) => void;
+}
+
+/**
+ * Ordered list of agent tasks. Steps run sequentially in the order shown.
+ * Reordering is done with up/down buttons to avoid a drag-and-drop dependency.
+ */
+export function TaskList({ tasks, errors, searchSpaceId, onChange }: TaskListProps) {
+ function updateAt(index: number, patch: Partial) {
+ onChange(tasks.map((task, i) => (i === index ? { ...task, ...patch } : task)));
+ }
+
+ function removeAt(index: number) {
+ onChange(tasks.filter((_, i) => i !== index));
+ }
+
+ function move(index: number, direction: -1 | 1) {
+ const target = index + direction;
+ if (target < 0 || target >= tasks.length) return;
+ const next = [...tasks];
+ [next[index], next[target]] = [next[target], next[index]];
+ onChange(next);
+ }
+
+ return (
+
+ {tasks.map((task, index) => (
+
updateAt(index, patch)}
+ onMoveUp={() => move(index, -1)}
+ onMoveDown={() => move(index, 1)}
+ onRemove={() => removeAt(index)}
+ />
+ ))}
+
+ {errors.tasks && {errors.tasks}
}
+
+ onChange([...tasks, emptyTask()])}
+ >
+
+ Add task
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/timezone-combobox.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/timezone-combobox.tsx
new file mode 100644
index 000000000..bc3b97542
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/timezone-combobox.tsx
@@ -0,0 +1,71 @@
+"use client";
+import { Check, ChevronsUpDown } from "lucide-react";
+import { useMemo, useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { getTimezones } from "@/lib/automations/builder-schema";
+import { cn } from "@/lib/utils";
+
+interface TimezoneComboboxProps {
+ value: string;
+ onChange: (value: string) => void;
+}
+
+/**
+ * Searchable IANA timezone picker. The full ``Intl.supportedValuesOf`` list is
+ * long, so it lives behind a Command search instead of a flat Select.
+ */
+export function TimezoneCombobox({ value, onChange }: TimezoneComboboxProps) {
+ const [open, setOpen] = useState(false);
+ const timezones = useMemo(() => getTimezones(), []);
+
+ return (
+
+
+
+ {value || "Select timezone"}
+
+
+
+
+
+
+
+ No timezone found.
+
+ {timezones.map((tz) => (
+ {
+ onChange(tz);
+ setOpen(false);
+ }}
+ >
+
+ {tz}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/unattended-toggle.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/unattended-toggle.tsx
new file mode 100644
index 000000000..ba665445f
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/unattended-toggle.tsx
@@ -0,0 +1,47 @@
+"use client";
+import { Info } from "lucide-react";
+import { Switch } from "@/components/ui/switch";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+
+interface UnattendedToggleProps {
+ checked: boolean;
+ onChange: (checked: boolean) => void;
+}
+
+/**
+ * Maps to ``auto_approve_all`` on every agent task. Automations run with no one
+ * watching, so this defaults ON; turning it off means any approval prompt the
+ * agent raises is rejected and the step can stall.
+ */
+export function UnattendedToggle({ checked, onChange }: UnattendedToggleProps) {
+ return (
+
+
+
+
+ Run without asking for approvals
+
+
+
+
+
+
+
+
+ Automations run unattended. With this off, any approval the agent asks for is
+ rejected, which can stall a step.
+
+
+
+
+ Auto-approve actions the agent would normally pause to confirm.
+
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/new/automation-new-content.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/new/automation-new-content.tsx
index f03b3f4c8..0c983aaf8 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/new/automation-new-content.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/new/automation-new-content.tsx
@@ -1,7 +1,7 @@
"use client";
import { ShieldAlert } from "lucide-react";
+import { AutomationBuilderForm } from "../components/builder/automation-builder-form";
import { useAutomationPermissions } from "../hooks/use-automation-permissions";
-import { AutomationJsonForm } from "./components/automation-json-form";
import { AutomationNewHeader } from "./components/automation-new-header";
interface AutomationNewContentProps {
@@ -9,10 +9,10 @@ interface AutomationNewContentProps {
}
/**
- * Orchestrator for the raw-JSON create route. Gates on
- * ``automations:create`` so users who can't create don't even see the
- * form; same panel as the detail page's access-denied state for
- * consistency.
+ * Orchestrator for the create route. Gates on ``automations:create`` so users
+ * who can't create don't even see the form; same panel as the detail page's
+ * access-denied state for consistency. The builder defaults to the friendly
+ * form with a raw-JSON escape hatch.
*/
export function AutomationNewContent({ searchSpaceId }: AutomationNewContentProps) {
const perms = useAutomationPermissions();
@@ -36,7 +36,7 @@ export function AutomationNewContent({ searchSpaceId }: AutomationNewContentProp
return (
<>
-
+
>
);
}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/new/components/automation-json-form.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/new/components/automation-json-form.tsx
deleted file mode 100644
index 94b608b8f..000000000
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/new/components/automation-json-form.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-"use client";
-import { useAtomValue } from "jotai";
-import { AlertCircle, FileJson, Save } from "lucide-react";
-import { useRouter } from "next/navigation";
-import { useState } from "react";
-import { createAutomationMutationAtom } from "@/atoms/automations/automations-mutation.atoms";
-import { JsonView } from "@/components/json-view";
-import { Button } from "@/components/ui/button";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Spinner } from "@/components/ui/spinner";
-import { automationCreateRequest } from "@/contracts/types/automation.types";
-import { DEFAULT_AUTOMATION_TEMPLATE } from "@/lib/automations/default-template";
-
-interface AutomationJsonFormProps {
- searchSpaceId: number;
-}
-
-/**
- * Raw-JSON create form. Lets power users skip the chat drafter when they
- * already know the shape they want. Flow:
- * edit tree → inject search_space_id → Zod validate → POST → navigate
- *
- * ``search_space_id`` is injected here rather than required in the edited
- * tree — the user shouldn't have to know their numeric id, and it keeps
- * the template copy-paste-friendly across search spaces.
- */
-export function AutomationJsonForm({ searchSpaceId }: AutomationJsonFormProps) {
- const router = useRouter();
- const { mutateAsync: createAutomation, isPending } = useAtomValue(createAutomationMutationAtom);
- const [value, setValue] = useState>(
- () => DEFAULT_AUTOMATION_TEMPLATE as Record
- );
- const [issues, setIssues] = useState([]);
-
- async function handleSubmit() {
- setIssues([]);
-
- const payload = { ...value, search_space_id: searchSpaceId };
- const result = automationCreateRequest.safeParse(payload);
- if (!result.success) {
- setIssues(
- result.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
- );
- return;
- }
-
- try {
- const created = await createAutomation(result.data);
- router.push(`/dashboard/${searchSpaceId}/automations/${created.id}`);
- } catch (err) {
- setIssues([(err as Error).message ?? "Submit failed"]);
- }
- }
-
- const hasIssues = issues.length > 0;
-
- return (
-
-
-
-
- Definition + triggers
-
-
-
-
- setValue(next as Record)}
- collapsed={false}
- />
-
-
- {hasIssues && (
-
-
-
- {issues.length === 1 ? "1 issue" : `${issues.length} issues`}
-
-
- {issues.map((issue) => (
- {issue}
- ))}
-
-
- )}
-
-
-
- {isPending ? : }
- Create automation
-
-
-
-
- );
-}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/new/components/automation-new-header.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/new/components/automation-new-header.tsx
index aef2744d5..ccfbbc9fa 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/new/components/automation-new-header.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/new/components/automation-new-header.tsx
@@ -22,12 +22,9 @@ export function AutomationNewHeader({ searchSpaceId }: AutomationNewHeaderProps)
-
- New automation · raw JSON
-
+
New automation
- Paste an ``AutomationCreate`` payload and submit. Validated against the schema before
- save. Prefer natural language? Use chat instead.
+ Set up a task and a schedule. Prefer natural language? Use chat instead.
diff --git a/surfsense_web/components/new-chat/document-mention-picker.tsx b/surfsense_web/components/new-chat/document-mention-picker.tsx
index c2a0f1665..8e3fd4ca8 100644
--- a/surfsense_web/components/new-chat/document-mention-picker.tsx
+++ b/surfsense_web/components/new-chat/document-mention-picker.tsx
@@ -57,6 +57,13 @@ interface DocumentMentionPickerProps {
onDone: () => void;
initialSelectedDocuments?: MentionedDocumentInfo[];
externalSearch?: string;
+ /**
+ * Whether to surface the "SurfSense Docs" (product documentation) branch
+ * and include those docs in search results. Defaults to ``true`` so the
+ * chat composer is unchanged; callers like the automation task input pass
+ * ``false`` to reference only the user's own knowledge base + connectors.
+ */
+ includeSurfsenseDocs?: boolean;
}
const PAGE_SIZE = 20;
@@ -228,7 +235,14 @@ export const DocumentMentionPicker = forwardRef<
DocumentMentionPickerRef,
DocumentMentionPickerProps
>(function DocumentMentionPicker(
- { searchSpaceId, onSelectionChange, onDone, initialSelectedDocuments = [], externalSearch = "" },
+ {
+ searchSpaceId,
+ onSelectionChange,
+ onDone,
+ initialSelectedDocuments = [],
+ externalSearch = "",
+ includeSurfsenseDocs = true,
+ },
ref
) {
const search = externalSearch;
@@ -307,7 +321,7 @@ export const DocumentMentionPicker = forwardRef<
queryFn: ({ signal }) =>
documentsApiService.getSurfsenseDocs({ queryParams: surfsenseDocsQueryParams }, signal),
staleTime: 3 * 60 * 1000,
- enabled: !hasSearch || isSearchValid,
+ enabled: includeSurfsenseDocs && (!hasSearch || isSearchValid),
placeholderData: keepPreviousData,
});
@@ -324,7 +338,7 @@ export const DocumentMentionPicker = forwardRef<
if (currentPage !== 0) return;
const combinedDocs: Pick[] = [];
- if (surfsenseDocs?.items) {
+ if (includeSurfsenseDocs && surfsenseDocs?.items) {
for (const doc of surfsenseDocs.items) {
combinedDocs.push({
id: doc.id,
@@ -340,7 +354,7 @@ export const DocumentMentionPicker = forwardRef<
}
setAccumulatedDocuments(filterBySearchTerm(combinedDocs));
- }, [titleSearchResults, surfsenseDocs, currentPage, filterBySearchTerm]);
+ }, [titleSearchResults, surfsenseDocs, currentPage, filterBySearchTerm, includeSurfsenseDocs]);
const loadNextPage = useCallback(async () => {
if (isLoadingMore || !hasMore) return;
@@ -449,7 +463,7 @@ export const DocumentMentionPicker = forwardRef<
() => new Set(initialSelectedDocuments.map((d) => getMentionDocKey(d))),
[initialSelectedDocuments]
);
- const showSurfsenseDocsRoot = surfsenseDocsList.length > 0;
+ const showSurfsenseDocsRoot = includeSurfsenseDocs && surfsenseDocsList.length > 0;
const selectMention = useCallback(
(mention: MentionedDocumentInfo) => {
diff --git a/surfsense_web/lib/automations/builder-schema.ts b/surfsense_web/lib/automations/builder-schema.ts
new file mode 100644
index 000000000..a6ed08c09
--- /dev/null
+++ b/surfsense_web/lib/automations/builder-schema.ts
@@ -0,0 +1,456 @@
+/**
+ * The form builder's own data model plus the mappers that bridge it to the
+ * backend contract (``automation.types.ts``).
+ *
+ * The builder deliberately exposes a *subset* of the full automation
+ * definition: a name, one or more natural-language agent tasks, a single
+ * schedule, and a few execution knobs. Anything richer (goal, per-step
+ * ``when`` predicates, ``inputs`` schema, ``on_failure`` steps, multiple or
+ * non-schedule triggers, custom metadata) is not representable here, so on
+ * edit we detect it and bounce the user to raw-JSON mode rather than silently
+ * dropping their data. ``goal`` is the one exception: it is carried through
+ * invisibly so the common drafter-produced automation stays form-editable.
+ */
+
+import { z } from "zod";
+import type { MentionedDocumentInfo } from "@/atoms/chat/mentioned-documents.atom";
+import {
+ type Automation,
+ type AutomationCreateRequest,
+ type AutomationDefinition,
+ type AutomationUpdateRequest,
+ execution as executionContract,
+ type TriggerCreateRequest,
+} from "@/contracts/types/automation.types";
+import { DEFAULT_SCHEDULE, fromCron, type ScheduleModel, toCron } from "./schedule-builder";
+
+const EXECUTION_DEFAULTS = executionContract.parse({});
+
+// ---------------------------------------------------------------------------
+// Form model
+// ---------------------------------------------------------------------------
+
+export const builderTaskSchema = z.object({
+ /** Client-side identity for stable React keys across reorder; not persisted. */
+ id: z.string(),
+ query: z.string().trim().min(1, "Describe what the agent should do"),
+ /**
+ * Files / folders / connectors @-mentioned in the query. Mirrors the chat
+ * composer's mention list and is forwarded to the run as step params so the
+ * agent scopes retrieval to them. The query text already carries ``@Title``
+ * for each; this is the structured side-channel of IDs.
+ */
+ mentions: z.array(z.custom()),
+ maxRetries: z.number().int().min(0).max(10).nullable(),
+ timeoutSeconds: z.number().int().positive().max(86_400).nullable(),
+});
+export type BuilderTask = z.infer;
+
+export const builderScheduleSchema = z.discriminatedUnion("mode", [
+ z.object({
+ mode: z.literal("preset"),
+ model: z.custom(),
+ }),
+ z.object({
+ mode: z.literal("cron"),
+ cron: z.string().trim().min(1, "Enter a schedule expression"),
+ }),
+]);
+export type BuilderSchedule = z.infer;
+
+export const builderExecutionSchema = z.object({
+ timeoutSeconds: z.number().int().positive().max(86_400),
+ maxRetries: z.number().int().min(0).max(10),
+ retryBackoff: z.enum(["exponential", "linear", "none"]),
+ concurrency: z.enum(["drop_if_running", "queue", "always"]),
+});
+export type BuilderExecution = z.infer;
+
+export const builderFormSchema = z.object({
+ name: z.string().trim().min(1, "Give your automation a name").max(200),
+ description: z.string().trim().max(2000).nullable(),
+ tasks: z.array(builderTaskSchema).min(1, "Add at least one task"),
+ unattended: z.boolean(),
+ schedule: builderScheduleSchema.nullable(),
+ timezone: z.string().min(1),
+ execution: builderExecutionSchema,
+ tags: z.array(z.string()),
+ /** Carried through from an edited definition so we don't drop it. */
+ goal: z.string().nullable(),
+});
+export type BuilderForm = z.infer;
+
+// ---------------------------------------------------------------------------
+// Defaults / construction
+// ---------------------------------------------------------------------------
+
+export function getDefaultTimezone(): string {
+ try {
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
+ } catch {
+ return "UTC";
+ }
+}
+
+export function getTimezones(): string[] {
+ try {
+ const supported = (
+ Intl as unknown as { supportedValuesOf?: (key: string) => string[] }
+ ).supportedValuesOf?.("timeZone");
+ if (supported && supported.length > 0) return supported;
+ } catch {
+ // fall through
+ }
+ return ["UTC", getDefaultTimezone()];
+}
+
+function newId(): string {
+ try {
+ return crypto.randomUUID();
+ } catch {
+ return `task_${Math.random().toString(36).slice(2)}`;
+ }
+}
+
+export function emptyTask(): BuilderTask {
+ return { id: newId(), query: "", mentions: [], maxRetries: null, timeoutSeconds: null };
+}
+
+export function createEmptyForm(): BuilderForm {
+ return {
+ name: "",
+ description: null,
+ tasks: [emptyTask()],
+ unattended: true,
+ schedule: { mode: "preset", model: { ...DEFAULT_SCHEDULE } },
+ timezone: getDefaultTimezone(),
+ execution: {
+ timeoutSeconds: EXECUTION_DEFAULTS.timeout_seconds,
+ maxRetries: EXECUTION_DEFAULTS.max_retries,
+ retryBackoff: EXECUTION_DEFAULTS.retry_backoff,
+ concurrency: EXECUTION_DEFAULTS.concurrency,
+ },
+ tags: [],
+ goal: null,
+ };
+}
+
+/** The cron string a schedule resolves to, regardless of preset/raw mode. */
+export function scheduleToCron(schedule: BuilderSchedule): string {
+ return schedule.mode === "preset" ? toCron(schedule.model) : schedule.cron.trim();
+}
+
+// ---------------------------------------------------------------------------
+// Form -> contract payloads
+// ---------------------------------------------------------------------------
+
+/**
+ * Project a task's @-mentions into the ``agent_task`` param fields the backend
+ * understands (the same names the chat ``new_chat`` request uses, minus
+ * SurfSense docs). Returns an empty object when there are no mentions so the
+ * params stay clean. ``mentioned_documents`` carries full chip metadata so the
+ * run can resolve titles/paths and the form can round-trip the chips back.
+ */
+function mentionParams(mentions: MentionedDocumentInfo[]): Record {
+ if (mentions.length === 0) return {};
+ const documentIds: number[] = [];
+ const folderIds: number[] = [];
+ const connectorIds: number[] = [];
+ const connectors: MentionedDocumentInfo[] = [];
+ for (const mention of mentions) {
+ if (mention.kind === "folder") {
+ folderIds.push(mention.id);
+ } else if (mention.kind === "connector") {
+ connectorIds.push(mention.id);
+ connectors.push(mention);
+ } else {
+ documentIds.push(mention.id);
+ }
+ }
+ const out: Record = { mentioned_documents: mentions };
+ if (documentIds.length > 0) out.mentioned_document_ids = documentIds;
+ if (folderIds.length > 0) out.mentioned_folder_ids = folderIds;
+ if (connectorIds.length > 0) {
+ out.mentioned_connector_ids = connectorIds;
+ out.mentioned_connectors = connectors;
+ }
+ return out;
+}
+
+function buildPlan(form: BuilderForm) {
+ return form.tasks.map((task, index) => {
+ const step: Record = {
+ step_id: `step_${index + 1}`,
+ action: "agent_task",
+ params: {
+ query: task.query.trim(),
+ auto_approve_all: form.unattended,
+ ...mentionParams(task.mentions),
+ },
+ };
+ if (task.maxRetries !== null) step.max_retries = task.maxRetries;
+ if (task.timeoutSeconds !== null) step.timeout_seconds = task.timeoutSeconds;
+ return step;
+ });
+}
+
+function buildDefinition(form: BuilderForm): AutomationDefinition {
+ return {
+ schema_version: "1.0",
+ name: form.name.trim(),
+ goal: form.goal,
+ // Triggers are attached at the top level of the create payload, not in
+ // the definition; the in-definition list stays empty.
+ triggers: [],
+ plan: buildPlan(form),
+ execution: {
+ timeout_seconds: form.execution.timeoutSeconds,
+ max_retries: form.execution.maxRetries,
+ retry_backoff: form.execution.retryBackoff,
+ concurrency: form.execution.concurrency,
+ on_failure: [],
+ },
+ metadata: { tags: form.tags },
+ } as unknown as AutomationDefinition;
+}
+
+/** The desired schedule trigger for this form, or ``null`` if none. */
+export function buildScheduleTrigger(form: BuilderForm): TriggerCreateRequest | null {
+ if (!form.schedule) return null;
+ return {
+ type: "schedule",
+ params: { cron: scheduleToCron(form.schedule), timezone: form.timezone },
+ static_inputs: {},
+ enabled: true,
+ };
+}
+
+export function buildCreatePayload(
+ form: BuilderForm,
+ searchSpaceId: number
+): AutomationCreateRequest {
+ const trigger = buildScheduleTrigger(form);
+ return {
+ search_space_id: searchSpaceId,
+ name: form.name.trim(),
+ description: form.description?.trim() ? form.description.trim() : null,
+ definition: buildDefinition(form),
+ triggers: trigger ? [trigger] : [],
+ };
+}
+
+export function buildUpdatePayload(form: BuilderForm): AutomationUpdateRequest {
+ return {
+ name: form.name.trim(),
+ description: form.description?.trim() ? form.description.trim() : null,
+ definition: buildDefinition(form),
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Contract -> form (edit hydration with safe fallback)
+// ---------------------------------------------------------------------------
+
+export type HydrateResult =
+ | { formable: true; form: BuilderForm }
+ | { formable: false; reason: string };
+
+/** A trigger as seen by the hydrator: both ``Trigger`` and ``TriggerCreateRequest`` fit. */
+export interface HydratableTrigger {
+ type: string;
+ params: Record;
+}
+
+const BACKOFF_VALUES = ["exponential", "linear", "none"] as const;
+const CONCURRENCY_VALUES = ["drop_if_running", "queue", "always"] as const;
+
+function asRecord(value: unknown): Record {
+ return value && typeof value === "object" ? (value as Record) : {};
+}
+
+/** Best-effort projection of a stored ``mentioned_documents`` entry into a chip. */
+function coerceMention(raw: unknown): MentionedDocumentInfo | null {
+ const o = asRecord(raw);
+ if (typeof o.id !== "number" || typeof o.title !== "string") return null;
+ if (o.kind === "folder") {
+ return { id: o.id, title: o.title, kind: "folder" };
+ }
+ if (o.kind === "connector") {
+ if (typeof o.connector_type !== "string" || typeof o.account_name !== "string") return null;
+ return {
+ id: o.id,
+ title: o.title,
+ kind: "connector",
+ connector_type: o.connector_type,
+ account_name: o.account_name,
+ };
+ }
+ return {
+ id: o.id,
+ title: o.title,
+ kind: "doc",
+ document_type: typeof o.document_type === "string" ? o.document_type : "UNKNOWN",
+ };
+}
+
+/**
+ * Rebuild a task's mention chips from step params. Returns ``null`` when the
+ * step carries mention IDs that aren't backed by usable ``mentioned_documents``
+ * metadata (e.g. hand-edited JSON), so the caller can fall back to JSON mode
+ * rather than silently dropping those IDs on the next save.
+ */
+function mentionsFromParams(params: Record): MentionedDocumentInfo[] | null {
+ const rawList = Array.isArray(params.mentioned_documents) ? params.mentioned_documents : [];
+ const mentions: MentionedDocumentInfo[] = [];
+ for (const raw of rawList) {
+ const mention = coerceMention(raw);
+ if (mention) mentions.push(mention);
+ }
+
+ const haveByKind = {
+ doc: new Set(mentions.filter((m) => m.kind === "doc").map((m) => m.id)),
+ folder: new Set(mentions.filter((m) => m.kind === "folder").map((m) => m.id)),
+ connector: new Set(mentions.filter((m) => m.kind === "connector").map((m) => m.id)),
+ };
+ const idChecks: Array<[unknown, Set]> = [
+ [params.mentioned_document_ids, haveByKind.doc],
+ [params.mentioned_folder_ids, haveByKind.folder],
+ [params.mentioned_connector_ids, haveByKind.connector],
+ ];
+ for (const [arr, have] of idChecks) {
+ if (!Array.isArray(arr)) continue;
+ for (const id of arr) {
+ if (typeof id === "number" && !have.has(id)) return null;
+ }
+ }
+ return mentions;
+}
+
+/**
+ * Core projection of a definition + triggers into the builder form. Returns
+ * ``formable: false`` whenever something can't be represented, so the caller
+ * can drop into raw-JSON mode without losing data. Shared by the edit
+ * hydrator and the JSON-mode round-trip.
+ *
+ * The definition is read defensively (``unknown``) so a partially edited JSON
+ * tree can still round-trip into the form; completeness is enforced by the
+ * form's own validation at submit time, not here.
+ */
+export function hydrateForm(
+ name: string,
+ description: string | null,
+ def: unknown,
+ triggers: HydratableTrigger[]
+): HydrateResult {
+ const d = asRecord(def);
+
+ if (d.inputs) {
+ return { formable: false, reason: "uses an inputs schema" };
+ }
+
+ const exec = asRecord(d.execution);
+ const onFailure = Array.isArray(exec.on_failure) ? exec.on_failure : [];
+ if (onFailure.length > 0) {
+ return { formable: false, reason: "has on-failure steps" };
+ }
+
+ const metadata = asRecord(d.metadata);
+ const extraMetadataKeys = Object.keys(metadata).filter((key) => key !== "tags");
+ if (extraMetadataKeys.length > 0) {
+ return { formable: false, reason: "has custom metadata" };
+ }
+
+ const plan = Array.isArray(d.plan) ? d.plan : [];
+ const tasks: BuilderTask[] = [];
+ let unattended = true;
+ for (const rawStep of plan) {
+ const step = asRecord(rawStep);
+ if (step.action !== "agent_task") {
+ return { formable: false, reason: `uses the "${String(step.action)}" action` };
+ }
+ if (step.when) {
+ return { formable: false, reason: "uses conditional steps" };
+ }
+ const params = asRecord(step.params);
+ const query = typeof params.query === "string" ? params.query : "";
+ // auto_approve_all is a single global toggle in the form; if any step is
+ // explicitly false we surface the toggle as off.
+ if (params.auto_approve_all === false) unattended = false;
+ const mentions = mentionsFromParams(params);
+ if (mentions === null) {
+ return { formable: false, reason: "references mentions without metadata" };
+ }
+ tasks.push({
+ id: newId(),
+ query,
+ mentions,
+ maxRetries: typeof step.max_retries === "number" ? step.max_retries : null,
+ timeoutSeconds: typeof step.timeout_seconds === "number" ? step.timeout_seconds : null,
+ });
+ }
+ if (tasks.length === 0) {
+ return { formable: false, reason: "has no steps" };
+ }
+
+ if (triggers.length > 1) {
+ return { formable: false, reason: "has multiple triggers" };
+ }
+ const trigger = triggers[0];
+ let schedule: BuilderSchedule | null = null;
+ let timezone = getDefaultTimezone();
+ if (trigger) {
+ if (trigger.type !== "schedule") {
+ return { formable: false, reason: `has a "${trigger.type}" trigger` };
+ }
+ const cron = typeof trigger.params?.cron === "string" ? trigger.params.cron : "";
+ timezone = typeof trigger.params?.timezone === "string" ? trigger.params.timezone : timezone;
+ const model = fromCron(cron);
+ schedule = model ? { mode: "preset", model } : { mode: "cron", cron };
+ }
+
+ const retryBackoff = BACKOFF_VALUES.includes(exec.retry_backoff as never)
+ ? (exec.retry_backoff as BuilderExecution["retryBackoff"])
+ : EXECUTION_DEFAULTS.retry_backoff;
+ const concurrency = CONCURRENCY_VALUES.includes(exec.concurrency as never)
+ ? (exec.concurrency as BuilderExecution["concurrency"])
+ : EXECUTION_DEFAULTS.concurrency;
+ const tags = Array.isArray(metadata.tags)
+ ? metadata.tags.filter((tag): tag is string => typeof tag === "string")
+ : [];
+
+ return {
+ formable: true,
+ form: {
+ name,
+ description: description ?? null,
+ tasks,
+ unattended,
+ schedule,
+ timezone,
+ execution: {
+ timeoutSeconds:
+ typeof exec.timeout_seconds === "number"
+ ? exec.timeout_seconds
+ : EXECUTION_DEFAULTS.timeout_seconds,
+ maxRetries:
+ typeof exec.max_retries === "number" ? exec.max_retries : EXECUTION_DEFAULTS.max_retries,
+ retryBackoff,
+ concurrency,
+ },
+ tags,
+ goal: typeof d.goal === "string" ? d.goal : null,
+ },
+ };
+}
+
+/**
+ * Project an existing automation into the builder form for editing.
+ */
+export function formFromAutomation(automation: Automation): HydrateResult {
+ return hydrateForm(
+ automation.name,
+ automation.description ?? null,
+ automation.definition,
+ automation.triggers ?? []
+ );
+}
diff --git a/surfsense_web/lib/automations/default-template.ts b/surfsense_web/lib/automations/default-template.ts
deleted file mode 100644
index 8963992cb..000000000
--- a/surfsense_web/lib/automations/default-template.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-/**
- * Minimal valid ``AutomationCreate`` skeleton used to seed the raw-JSON
- * create form. ``search_space_id`` is omitted on purpose — the form
- * injects it from the route so users never have to know their id.
- *
- * The shape matches the Pydantic ``AutomationCreate`` model less the
- * search_space_id field; Zod validates the merged payload before submit.
- */
-export const DEFAULT_AUTOMATION_TEMPLATE = {
- name: "My automation",
- description: null,
- definition: {
- name: "My automation",
- goal: null,
- plan: [
- {
- step_id: "step_1",
- action: "agent_task",
- params: {
- query: "Summarize new docs added to folder 12 since the last run.",
- },
- },
- ],
- execution: {
- timeout_seconds: 600,
- max_retries: 2,
- retry_backoff: "exponential",
- concurrency: "drop_if_running",
- on_failure: [],
- },
- metadata: { tags: [] },
- },
- triggers: [
- {
- type: "schedule",
- params: {
- cron: "0 9 * * 1-5",
- timezone: "UTC",
- },
- static_inputs: {},
- enabled: true,
- },
- ],
-} as const;
diff --git a/surfsense_web/lib/automations/schedule-builder.ts b/surfsense_web/lib/automations/schedule-builder.ts
new file mode 100644
index 000000000..37f4cfa14
--- /dev/null
+++ b/surfsense_web/lib/automations/schedule-builder.ts
@@ -0,0 +1,132 @@
+/**
+ * Bidirectional bridge between a friendly schedule model and the 5-field cron
+ * expression the backend ``schedule`` trigger expects (see
+ * ``app/automations/triggers/schedule/params.py``).
+ *
+ * The form builder never asks users to type cron. They pick a frequency + time
+ * (+ days), which ``toCron`` compiles. On edit we ``fromCron`` an existing
+ * expression back into the model; anything we don't recognize returns ``null``
+ * so the caller can fall back to a raw-cron escape hatch instead of silently
+ * losing the user's schedule.
+ *
+ * The recognized patterns are intentionally the same family that
+ * ``describe-cron.ts`` humanizes, keeping the picker and the label in sync.
+ */
+
+export type ScheduleFrequency = "hourly" | "daily" | "weekdays" | "weekly" | "monthly";
+
+export interface ScheduleModel {
+ frequency: ScheduleFrequency;
+ /** 0-23. Ignored for ``hourly``. */
+ hour: number;
+ /** 0-59. */
+ minute: number;
+ /** 0 (Sun) - 6 (Sat). Used by ``weekly``. */
+ daysOfWeek: number[];
+ /** 1-31. Used by ``monthly``. */
+ dayOfMonth: number;
+}
+
+/** Sunday-first, matching cron's 0-6 day-of-week numbering. */
+export const WEEKDAY_OPTIONS: ReadonlyArray<{ value: number; short: string; long: string }> = [
+ { value: 1, short: "Mon", long: "Monday" },
+ { value: 2, short: "Tue", long: "Tuesday" },
+ { value: 3, short: "Wed", long: "Wednesday" },
+ { value: 4, short: "Thu", long: "Thursday" },
+ { value: 5, short: "Fri", long: "Friday" },
+ { value: 6, short: "Sat", long: "Saturday" },
+ { value: 0, short: "Sun", long: "Sunday" },
+];
+
+export const FREQUENCY_OPTIONS: ReadonlyArray<{ value: ScheduleFrequency; label: string }> = [
+ { value: "hourly", label: "Every hour" },
+ { value: "daily", label: "Every day" },
+ { value: "weekdays", label: "Every weekday (Mon\u2013Fri)" },
+ { value: "weekly", label: "Specific days of the week" },
+ { value: "monthly", label: "Once a month" },
+];
+
+export const DEFAULT_SCHEDULE: ScheduleModel = {
+ frequency: "weekdays",
+ hour: 9,
+ minute: 0,
+ daysOfWeek: [1],
+ dayOfMonth: 1,
+};
+
+function isInt(value: string): boolean {
+ return /^\d+$/.test(value);
+}
+
+function clamp(value: number, min: number, max: number): number {
+ if (Number.isNaN(value)) return min;
+ return Math.min(max, Math.max(min, value));
+}
+
+/** Compile a schedule model into a 5-field cron expression. */
+export function toCron(model: ScheduleModel): string {
+ const minute = clamp(model.minute, 0, 59);
+ const hour = clamp(model.hour, 0, 23);
+
+ switch (model.frequency) {
+ case "hourly":
+ return `${minute} * * * *`;
+ case "daily":
+ return `${minute} ${hour} * * *`;
+ case "weekdays":
+ return `${minute} ${hour} * * 1-5`;
+ case "weekly": {
+ const days = [...new Set(model.daysOfWeek)].sort((a, b) => a - b);
+ // Guard against an empty selection producing an invalid cron.
+ const dow = days.length > 0 ? days.join(",") : "1";
+ return `${minute} ${hour} * * ${dow}`;
+ }
+ case "monthly":
+ return `${minute} ${hour} ${clamp(model.dayOfMonth, 1, 31)} * *`;
+ }
+}
+
+/**
+ * Parse a 5-field cron expression back into a schedule model. Returns ``null``
+ * for anything outside the recognized pattern family so callers can fall back
+ * to the raw-cron field.
+ */
+export function fromCron(cron: string): ScheduleModel | null {
+ const parts = cron.trim().split(/\s+/);
+ if (parts.length !== 5) return null;
+
+ const [minute, hour, dom, month, dow] = parts;
+
+ // Hourly: "M * * * *"
+ if (month === "*" && dom === "*" && dow === "*" && hour === "*" && isInt(minute)) {
+ return { ...DEFAULT_SCHEDULE, frequency: "hourly", minute: Number(minute) };
+ }
+
+ // Everything below requires concrete minute + hour.
+ if (!isInt(minute) || !isInt(hour)) return null;
+
+ const base = { hour: Number(hour), minute: Number(minute) };
+
+ // Daily: "M H * * *"
+ if (month === "*" && dom === "*" && dow === "*") {
+ return { ...DEFAULT_SCHEDULE, ...base, frequency: "daily" };
+ }
+
+ // Weekdays: "M H * * 1-5"
+ if (month === "*" && dom === "*" && dow === "1-5") {
+ return { ...DEFAULT_SCHEDULE, ...base, frequency: "weekdays" };
+ }
+
+ // Weekly: "M H * * 1,3,5"
+ if (month === "*" && dom === "*" && /^[0-6](,[0-6])*$/.test(dow)) {
+ const daysOfWeek = [...new Set(dow.split(",").map(Number))].sort((a, b) => a - b);
+ return { ...DEFAULT_SCHEDULE, ...base, frequency: "weekly", daysOfWeek };
+ }
+
+ // Monthly: "M H D * *"
+ if (month === "*" && dow === "*" && isInt(dom)) {
+ return { ...DEFAULT_SCHEDULE, ...base, frequency: "monthly", dayOfMonth: Number(dom) };
+ }
+
+ return null;
+}
From 6b76f8c138eea5a9f0c7d102310286bcc62f2c75 Mon Sep 17 00:00:00 2001
From: "DESKTOP-RTLN3BA\\$punk"
Date: Thu, 28 May 2026 21:29:24 -0700
Subject: [PATCH 4/6] refactor(automations): update icons and button labels in
empty state and header components
- Replaced the FileJson icon with SquarePen in both AutomationsEmptyState and AutomationsHeader components.
- Updated button label from "Create via JSON" to "Create manually" for clarity in the automation creation process.
---
.../automations/components/automations-empty-state.tsx | 6 +++---
.../automations/components/automations-header.tsx | 6 +++---
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-empty-state.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-empty-state.tsx
index 83fa52fa8..cc54c5e94 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-empty-state.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-empty-state.tsx
@@ -1,5 +1,5 @@
"use client";
-import { FileJson, MessageSquarePlus, Workflow } from "lucide-react";
+import { MessageSquarePlus, SquarePen, Workflow } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
@@ -35,8 +35,8 @@ export function AutomationsEmptyState({ searchSpaceId, canCreate }: AutomationsE
-
- Create via JSON
+
+ Create manually
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-header.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-header.tsx
index 544c6b7ac..8d5fab033 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-header.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-header.tsx
@@ -1,5 +1,5 @@
"use client";
-import { FileJson, MessageSquarePlus } from "lucide-react";
+import { MessageSquarePlus, SquarePen } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
@@ -42,8 +42,8 @@ export function AutomationsHeader({
-
- Create via JSON
+
+ Create manually
From 9b9e6828c7bb5f28da0ff2006f8689583377704a Mon Sep 17 00:00:00 2001
From: "DESKTOP-RTLN3BA\\$punk"
Date: Thu, 28 May 2026 21:44:22 -0700
Subject: [PATCH 5/6] refactor(automations): enhance mention handling in task
parameters
- Updated the `mentionParams` function to separate document and connector mentions, improving clarity and organization of the output.
- Modified the `mentionsFromParams` function to correctly handle and categorize mentions from parameters, ensuring connectors are processed separately.
- Adjusted documentation comments for better understanding of the changes in mention handling.
---
.../lib/automations/builder-schema.ts | 36 +++++++++++++------
1 file changed, 26 insertions(+), 10 deletions(-)
diff --git a/surfsense_web/lib/automations/builder-schema.ts b/surfsense_web/lib/automations/builder-schema.ts
index a6ed08c09..a9349c56f 100644
--- a/surfsense_web/lib/automations/builder-schema.ts
+++ b/surfsense_web/lib/automations/builder-schema.ts
@@ -148,26 +148,33 @@ export function scheduleToCron(schedule: BuilderSchedule): string {
* Project a task's @-mentions into the ``agent_task`` param fields the backend
* understands (the same names the chat ``new_chat`` request uses, minus
* SurfSense docs). Returns an empty object when there are no mentions so the
- * params stay clean. ``mentioned_documents`` carries full chip metadata so the
- * run can resolve titles/paths and the form can round-trip the chips back.
+ * params stay clean.
+ *
+ * ``mentioned_documents`` carries doc/folder chip metadata (so the run can
+ * resolve titles to paths); connectors live only in ``mentioned_connectors`` /
+ * ``mentioned_connector_ids`` to avoid duplicating them across buckets.
*/
function mentionParams(mentions: MentionedDocumentInfo[]): Record {
if (mentions.length === 0) return {};
const documentIds: number[] = [];
const folderIds: number[] = [];
const connectorIds: number[] = [];
+ const documents: MentionedDocumentInfo[] = [];
const connectors: MentionedDocumentInfo[] = [];
for (const mention of mentions) {
if (mention.kind === "folder") {
folderIds.push(mention.id);
+ documents.push(mention);
} else if (mention.kind === "connector") {
connectorIds.push(mention.id);
connectors.push(mention);
} else {
documentIds.push(mention.id);
+ documents.push(mention);
}
}
- const out: Record = { mentioned_documents: mentions };
+ const out: Record = {};
+ if (documents.length > 0) out.mentioned_documents = documents;
if (documentIds.length > 0) out.mentioned_document_ids = documentIds;
if (folderIds.length > 0) out.mentioned_folder_ids = folderIds;
if (connectorIds.length > 0) {
@@ -294,17 +301,26 @@ function coerceMention(raw: unknown): MentionedDocumentInfo | null {
}
/**
- * Rebuild a task's mention chips from step params. Returns ``null`` when the
- * step carries mention IDs that aren't backed by usable ``mentioned_documents``
- * metadata (e.g. hand-edited JSON), so the caller can fall back to JSON mode
- * rather than silently dropping those IDs on the next save.
+ * Rebuild a task's mention chips from step params. Doc/folder chips come from
+ * ``mentioned_documents``; connector chips from ``mentioned_connectors`` (kept
+ * in their own bucket). Returns ``null`` when the step carries mention IDs that
+ * aren't backed by usable chip metadata (e.g. hand-edited JSON), so the caller
+ * can fall back to JSON mode rather than silently dropping those IDs on save.
*/
function mentionsFromParams(params: Record): MentionedDocumentInfo[] | null {
- const rawList = Array.isArray(params.mentioned_documents) ? params.mentioned_documents : [];
const mentions: MentionedDocumentInfo[] = [];
- for (const raw of rawList) {
+ const docList = Array.isArray(params.mentioned_documents) ? params.mentioned_documents : [];
+ for (const raw of docList) {
const mention = coerceMention(raw);
- if (mention) mentions.push(mention);
+ // Connectors belong in their own bucket; ignore any that leak in here.
+ if (mention && mention.kind !== "connector") mentions.push(mention);
+ }
+ const connectorList = Array.isArray(params.mentioned_connectors)
+ ? params.mentioned_connectors
+ : [];
+ for (const raw of connectorList) {
+ const mention = coerceMention(raw);
+ if (mention && mention.kind === "connector") mentions.push(mention);
}
const haveByKind = {
From 40ca9e6ed2d17b7a112fb9424c31fbedc9ae1e28 Mon Sep 17 00:00:00 2001
From: "DESKTOP-RTLN3BA\\$punk"
Date: Thu, 28 May 2026 22:35:14 -0700
Subject: [PATCH 6/6] refactor: remove `search_surfsense_docs` tool and related
references
- Deleted the `search_surfsense_docs` tool and its associated files, streamlining the agent's toolset.
- Updated various components and prompts to remove references to the now-removed tool, ensuring consistency across the codebase.
- Adjusted documentation to direct users to the SurfSense documentation link for product-related queries instead.
---
.../146_drop_surfsense_docs_tables.py | 129 +++++++++
.../system_prompt/prompts/citations/on.md | 4 +-
.../prompts/dynamic_context/private.md | 4 +-
.../prompts/dynamic_context/team.md | 4 +-
.../system_prompt/prompts/kb_first.md | 8 +-
.../prompts/providers/anthropic.md | 2 +-
.../prompts/providers/deepseek.md | 2 +-
.../system_prompt/prompts/providers/google.md | 2 +-
.../prompts/providers/openai_classic.md | 5 +-
.../system_prompt/prompts/routing.md | 9 +-
.../tools/search_surfsense_docs/__init__.py | 1 -
.../search_surfsense_docs/description.md | 10 -
.../tools/search_surfsense_docs/example.md | 15 --
.../main_agent/tools/index.py | 1 -
.../builtins/research/system_prompt.md | 1 -
.../builtins/research/tools/__init__.py | 4 +-
.../builtins/research/tools/index.py | 2 -
.../research/tools/search_surfsense_docs.py | 145 ----------
.../app/agents/new_chat/feature_flags.py | 2 +-
.../app/agents/new_chat/mention_resolver.py | 10 +-
.../new_chat/prompts/base/citations_on.md | 3 +-
.../prompts/base/kb_only_policy_private.md | 2 +-
.../prompts/base/kb_only_policy_team.md | 2 +-
.../prompts/base/tool_routing_private.md | 1 +
.../prompts/base/tool_routing_team.md | 1 +
.../app/agents/new_chat/prompts/composer.py | 1 -
.../prompts/examples/search_surfsense_docs.md | 9 -
.../prompts/tools/search_surfsense_docs.md | 7 -
.../skills/builtin/email-drafting/SKILL.md | 1 -
.../skills/builtin/kb-research/SKILL.md | 2 +-
.../skills/builtin/meeting-prep/SKILL.md | 2 +-
.../skills/builtin/report-writing/SKILL.md | 2 +-
.../skills/builtin/slack-summary/SKILL.md | 1 -
.../app/agents/new_chat/subagents/config.py | 5 +-
.../app/agents/new_chat/tools/__init__.py | 3 -
.../app/agents/new_chat/tools/registry.py | 10 -
.../new_chat/tools/search_surfsense_docs.py | 174 ------------
surfsense_backend/app/app.py | 8 -
surfsense_backend/app/db.py | 45 ----
surfsense_backend/app/routes/__init__.py | 2 -
.../app/routes/new_chat_routes.py | 2 -
.../app/routes/surfsense_docs_routes.py | 172 ------------
surfsense_backend/app/schemas/new_chat.py | 4 -
.../app/schemas/surfsense_docs.py | 43 ---
.../app/tasks/chat/stream_new_chat.py | 112 +-------
.../tasks/chat/streaming/context/__init__.py | 6 +-
.../chat/streaming/context/mentioned_docs.py | 58 ----
.../flows/new_chat/initial_thinking_step.py | 26 +-
.../streaming/flows/new_chat/input_state.py | 51 +---
.../streaming/flows/new_chat/orchestrator.py | 6 +-
.../app/tasks/surfsense_docs_indexer.py | 249 ------------------
surfsense_backend/app/utils/surfsense_docs.py | 13 -
.../scripts/seed_surfsense_docs.py | 40 ---
.../test_default_permissions_layering.py | 1 -
.../new_chat/test_specialized_subagents.py | 14 +-
.../test_parallel_refactor_parity.py | 35 +--
.../components/builder/mention-task-input.tsx | 9 +-
.../new-chat/[[...chat_id]]/page.tsx | 36 +--
.../atoms/chat/mentioned-documents.atom.ts | 5 +-
.../assistant-ui/inline-citation.tsx | 159 +----------
.../components/assistant-ui/thread.tsx | 2 +-
.../layout/ui/sidebar/DocumentsSidebar.tsx | 1 -
.../new-chat/document-mention-picker.tsx | 113 ++------
.../contracts/enums/connectorIcons.tsx | 3 -
surfsense_web/contracts/enums/toolIcons.tsx | 3 -
.../contracts/types/document.types.ts | 55 ----
.../lib/apis/documents-api.service.ts | 46 ----
surfsense_web/lib/chat/thread-persistence.ts | 1 -
.../lib/documents/document-type-labels.ts | 1 -
surfsense_web/lib/query-client/cache-keys.ts | 1 -
surfsense_web/tsc_out.txt | Bin 0 -> 32582 bytes
71 files changed, 232 insertions(+), 1676 deletions(-)
create mode 100644 surfsense_backend/alembic/versions/146_drop_surfsense_docs_tables.py
delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/search_surfsense_docs/__init__.py
delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/search_surfsense_docs/description.md
delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/search_surfsense_docs/example.md
delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/search_surfsense_docs.py
delete mode 100644 surfsense_backend/app/agents/new_chat/prompts/examples/search_surfsense_docs.md
delete mode 100644 surfsense_backend/app/agents/new_chat/prompts/tools/search_surfsense_docs.md
delete mode 100644 surfsense_backend/app/agents/new_chat/tools/search_surfsense_docs.py
delete mode 100644 surfsense_backend/app/routes/surfsense_docs_routes.py
delete mode 100644 surfsense_backend/app/schemas/surfsense_docs.py
delete mode 100644 surfsense_backend/app/tasks/chat/streaming/context/mentioned_docs.py
delete mode 100644 surfsense_backend/app/tasks/surfsense_docs_indexer.py
delete mode 100644 surfsense_backend/app/utils/surfsense_docs.py
delete mode 100644 surfsense_backend/scripts/seed_surfsense_docs.py
create mode 100644 surfsense_web/tsc_out.txt
diff --git a/surfsense_backend/alembic/versions/146_drop_surfsense_docs_tables.py b/surfsense_backend/alembic/versions/146_drop_surfsense_docs_tables.py
new file mode 100644
index 000000000..725405834
--- /dev/null
+++ b/surfsense_backend/alembic/versions/146_drop_surfsense_docs_tables.py
@@ -0,0 +1,129 @@
+"""Drop Surfsense docs tables (feature removed end to end)
+
+Revision ID: 146
+Revises: 145
+Create Date: 2026-05-28
+
+Removes the SurfSense product-documentation feature: the
+``surfsense_docs_documents`` and ``surfsense_docs_chunks`` tables (created
+in revision 60) and the GIN trigram index on the title column (added in
+revision 67). The docs were seeded at startup from local MDX files, so no
+user data is lost. Downgrade recreates the tables and indexes.
+"""
+
+from collections.abc import Sequence
+
+from alembic import op
+from app.config import config
+
+# revision identifiers, used by Alembic.
+revision: str = "146"
+down_revision: str | None = "145"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+# Embedding dimension is required to recreate the vector columns on downgrade.
+EMBEDDING_DIM = config.embedding_model_instance.dimension
+
+
+def upgrade() -> None:
+ """Drop surfsense docs tables and all their indexes."""
+ # Trigram index from revision 67
+ op.execute("DROP INDEX IF EXISTS idx_surfsense_docs_title_trgm")
+
+ # Full-text search indexes
+ op.execute("DROP INDEX IF EXISTS surfsense_docs_chunks_search_index")
+ op.execute("DROP INDEX IF EXISTS surfsense_docs_documents_search_index")
+
+ # Vector indexes
+ op.execute("DROP INDEX IF EXISTS surfsense_docs_chunks_vector_index")
+ op.execute("DROP INDEX IF EXISTS surfsense_docs_documents_vector_index")
+
+ # B-tree indexes
+ op.execute("DROP INDEX IF EXISTS ix_surfsense_docs_chunks_document_id")
+ op.execute("DROP INDEX IF EXISTS ix_surfsense_docs_documents_updated_at")
+ op.execute("DROP INDEX IF EXISTS ix_surfsense_docs_documents_content_hash")
+ op.execute("DROP INDEX IF EXISTS ix_surfsense_docs_documents_source")
+
+ # Tables (chunks first due to FK)
+ op.execute("DROP TABLE IF EXISTS surfsense_docs_chunks")
+ op.execute("DROP TABLE IF EXISTS surfsense_docs_documents")
+
+
+def downgrade() -> None:
+ """Recreate surfsense docs tables and indexes (reverses revisions 60 + 67)."""
+ op.execute(
+ f"""
+ CREATE TABLE IF NOT EXISTS surfsense_docs_documents (
+ id SERIAL PRIMARY KEY,
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
+ source VARCHAR NOT NULL UNIQUE,
+ title VARCHAR NOT NULL,
+ content TEXT NOT NULL,
+ content_hash VARCHAR NOT NULL,
+ embedding vector({EMBEDDING_DIM}),
+ updated_at TIMESTAMP WITH TIME ZONE
+ );
+ """
+ )
+ op.execute(
+ f"""
+ CREATE TABLE IF NOT EXISTS surfsense_docs_chunks (
+ id SERIAL PRIMARY KEY,
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
+ content TEXT NOT NULL,
+ embedding vector({EMBEDDING_DIM}),
+ document_id INTEGER NOT NULL REFERENCES surfsense_docs_documents(id) ON DELETE CASCADE
+ );
+ """
+ )
+
+ # B-tree indexes
+ op.execute(
+ "CREATE INDEX IF NOT EXISTS ix_surfsense_docs_documents_source ON surfsense_docs_documents(source)"
+ )
+ op.execute(
+ "CREATE INDEX IF NOT EXISTS ix_surfsense_docs_documents_content_hash ON surfsense_docs_documents(content_hash)"
+ )
+ op.execute(
+ "CREATE INDEX IF NOT EXISTS ix_surfsense_docs_documents_updated_at ON surfsense_docs_documents(updated_at)"
+ )
+ op.execute(
+ "CREATE INDEX IF NOT EXISTS ix_surfsense_docs_chunks_document_id ON surfsense_docs_chunks(document_id)"
+ )
+
+ # Vector indexes
+ op.execute(
+ """
+ CREATE INDEX IF NOT EXISTS surfsense_docs_documents_vector_index
+ ON surfsense_docs_documents USING hnsw (embedding public.vector_cosine_ops);
+ """
+ )
+ op.execute(
+ """
+ CREATE INDEX IF NOT EXISTS surfsense_docs_chunks_vector_index
+ ON surfsense_docs_chunks USING hnsw (embedding public.vector_cosine_ops);
+ """
+ )
+
+ # Full-text search indexes
+ op.execute(
+ """
+ CREATE INDEX IF NOT EXISTS surfsense_docs_documents_search_index
+ ON surfsense_docs_documents USING gin (to_tsvector('english', content));
+ """
+ )
+ op.execute(
+ """
+ CREATE INDEX IF NOT EXISTS surfsense_docs_chunks_search_index
+ ON surfsense_docs_chunks USING gin (to_tsvector('english', content));
+ """
+ )
+
+ # Trigram index from revision 67
+ op.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_surfsense_docs_title_trgm
+ ON surfsense_docs_documents USING gin (title gin_trgm_ops);
+ """
+ )
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/citations/on.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/citations/on.md
index e61a0bffb..2abd95d5a 100644
--- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/citations/on.md
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/citations/on.md
@@ -4,8 +4,8 @@ never invent ids you didn't see. Citation ids are resolved by exact-match
lookup; a wrong id silently breaks the link, so when in doubt, omit.
### Channel A — chunk blocks injected this turn
-When `search_surfsense_docs` or `web_search` returns `` /
-`` blocks in this turn:
+When `web_search` returns `` / `` blocks in this
+turn:
1. For each factual statement taken from those chunks, add
`[citation:chunk_id]` using the **exact** id from a visible
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/dynamic_context/private.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/dynamic_context/private.md
index 71c86be40..8f2bfca4e 100644
--- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/dynamic_context/private.md
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/dynamic_context/private.md
@@ -20,8 +20,8 @@ it to resolve paths the user describes in natural language ("my Q2 roadmap",
delegating to a specialist.
`` and `` blocks are chunked indexed content returned
-by KB search (from `search_surfsense_docs`, or backing ``).
-Each chunk carries a stable `id` attribute.
+by KB search (backing ``). Each chunk carries a stable
+`id` attribute.
If a block doesn't appear this turn, work from the conversation alone.
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/dynamic_context/team.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/dynamic_context/team.md
index 592c2ed9c..a5892c23a 100644
--- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/dynamic_context/team.md
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/dynamic_context/team.md
@@ -20,8 +20,8 @@ week's planning notes") into concrete document references before delegating
to a specialist.
`` and `` blocks are chunked indexed content returned
-by KB search (from `search_surfsense_docs`, or backing ``).
-Each chunk carries a stable `id` attribute.
+by KB search (backing ``). Each chunk carries a stable
+`id` attribute.
If a block doesn't appear this turn, work from the conversation alone.
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/kb_first.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/kb_first.md
index f06a52c1d..80fa4bf8f 100644
--- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/kb_first.md
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/kb_first.md
@@ -1,19 +1,21 @@
CRITICAL — ground factual answers in what you actually receive this turn:
- injected workspace context (see ``),
-- results from your own tool calls (`search_surfsense_docs`, `web_search`,
- `scrape_webpage`),
+- results from your own tool calls (`web_search`, `scrape_webpage`),
- or substantive summaries returned by a `task` specialist you invoked.
Do **not** answer factual or informational questions from general knowledge
unless the user explicitly authorises it after you say you couldn't find
enough in those sources. The flow when nothing is found:
-1. Say you couldn't find enough in their workspace, docs, or tool output.
+1. Say you couldn't find enough in their workspace or tool output.
2. Ask: *"Would you like me to answer from my general knowledge instead?"*
3. Only answer from general knowledge after a clear yes.
This rule does NOT apply to: casual conversation · meta-questions about
SurfSense ("what can you do?") · formatting or analysis of content already
in chat · clear rewrite/edit instructions · lightweight web research.
+
+For "how do I use SurfSense" / product-documentation questions, point the
+user to https://www.surfsense.com/docs.
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/anthropic.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/anthropic.md
index 89154c443..d852f5955 100644
--- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/anthropic.md
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/anthropic.md
@@ -5,7 +5,7 @@ Structured reasoning:
- For non-trivial work, `` / short `` before tool calls is fine.
Professional objectivity:
-- Accuracy over flattery; verify with **search_surfsense_docs**, **web_search**, **scrape_webpage**, or **task** when unsure — don’t invent connector access.
+- Accuracy over flattery; verify with **web_search**, **scrape_webpage**, or **task** when unsure — don’t invent connector access.
Task management:
- For 3+ steps, use todo tooling; update statuses promptly.
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/deepseek.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/deepseek.md
index 4254e9ed5..01d56999f 100644
--- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/deepseek.md
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/deepseek.md
@@ -13,6 +13,6 @@ Attribution:
Tool calls:
- Parallelise independent calls.
-- Prefer **search_surfsense_docs** for SurfSense docs/product questions before **web_search** when that fits the ask.
+- For SurfSense docs/product questions, point the user to https://www.surfsense.com/docs.
- Don’t invent paths, chunk ids, or URLs — only values from tools or the user.
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/google.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/google.md
index dc5073538..32ed959c1 100644
--- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/google.md
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/google.md
@@ -7,7 +7,7 @@ Output style:
- GitHub-flavoured Markdown; monospace-friendly.
Workflow (Understand → Plan → Act → Verify):
-1. **Understand:** parse the ask; use **search_surfsense_docs** / injected workspace context before guessing.
+1. **Understand:** parse the ask; use injected workspace context before guessing.
2. **Plan:** for multi-step work, a short plan first.
3. **Act:** only with tools you actually have on this agent (see `` and ``). Connector work → **task**.
4. **Verify:** re-read or re-search only when it materially reduces risk.
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/openai_classic.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/openai_classic.md
index 7ff3ec912..8596c42cd 100644
--- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/openai_classic.md
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/providers/openai_classic.md
@@ -15,6 +15,7 @@ Output style:
Tool calls:
- Parallelise independent calls in one turn.
-- Prefer **search_surfsense_docs** for SurfSense-product questions, **web_search** / **scrape_webpage**
- for fresh public facts; integrations and heavy workflows → **task**.
+- For SurfSense-product questions, point the user to https://www.surfsense.com/docs;
+ use **web_search** / **scrape_webpage** for fresh public facts; integrations and
+ heavy workflows → **task**.
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/routing.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/routing.md
index 1038dde3d..28cf0ac63 100644
--- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/routing.md
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/routing.md
@@ -3,10 +3,7 @@ You have two execution channels. Pick the one that owns the work — never
simulate one with the other.
### 1. Direct tools (you call them yourself)
-- `search_surfsense_docs` — SurfSense product docs (setup, configuration,
- connector docs, feature behavior).
-- `web_search` — search the public web (anything outside SurfSense docs and
- the workspace KB).
+- `web_search` — search the public web (anything outside the workspace KB).
- `scrape_webpage` — fetch the body of a specific public URL.
- `update_memory` — curate persistent memory (see ``).
- `write_todos` — maintain a structured plan when the turn series spans
@@ -14,6 +11,10 @@ simulate one with the other.
`in_progress` **before** the `task` call that handles it, `completed`
once the call returns. Skip for single-step requests.
+**Questions about how to use SurfSense itself** (setup, configuration,
+connectors, feature behavior) — point the user to the documentation:
+https://www.surfsense.com/docs. There is no docs-search tool; give the link.
+
**You have NO filesystem tools.** Any read, write, edit, move, rename, or
search inside the user's workspace goes through `task(knowledge_base, …)` —
never via `write_file`, `ls`, or any direct file operation.
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/search_surfsense_docs/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/search_surfsense_docs/__init__.py
deleted file mode 100644
index c2cda318e..000000000
--- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/search_surfsense_docs/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""``search_surfsense_docs`` — description + few-shot examples."""
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/search_surfsense_docs/description.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/search_surfsense_docs/description.md
deleted file mode 100644
index 256d3f3a4..000000000
--- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/search_surfsense_docs/description.md
+++ /dev/null
@@ -1,10 +0,0 @@
-- `search_surfsense_docs` — Search official SurfSense documentation (product
- help).
- - Use when the user asks how SurfSense itself works — setup, configuration,
- connector documentation, feature behavior, anything covered in the
- product docs.
- - Not a substitute for `task` when the user wants actions inside a
- connected service (Gmail, Slack, Jira, Notion, etc.).
- - Args: `query`, `top_k` (default 10).
- - Returns doc excerpts; chunk ids may appear for attribution — see
- `` for the contract.
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/search_surfsense_docs/example.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/search_surfsense_docs/example.md
deleted file mode 100644
index d53ad8c91..000000000
--- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/search_surfsense_docs/example.md
+++ /dev/null
@@ -1,15 +0,0 @@
-
-user: "How do I install SurfSense?"
-→ search_surfsense_docs(query="installation setup")
-
-
-
-user: "What connectors does SurfSense support?"
-→ search_surfsense_docs(query="available connectors integrations")
-
-
-
-user: "How do I set up the Notion connector?"
-→ search_surfsense_docs(query="Notion connector setup configuration")
-(Changing data inside Notion itself → `task(notion, …)`, not this tool.)
-
diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/index.py
index 88509eda7..70fb42c0d 100644
--- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/index.py
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/index.py
@@ -6,7 +6,6 @@ Connector integrations, MCP, deliverables, etc. are delegated via ``task`` subag
from __future__ import annotations
MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED: tuple[str, ...] = (
- "search_surfsense_docs",
"web_search",
"scrape_webpage",
"update_memory",
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/system_prompt.md
index 3eabd8ee0..1b9ccaefa 100644
--- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/system_prompt.md
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/system_prompt.md
@@ -8,7 +8,6 @@ Gather and synthesize evidence using SurfSense research tools with clear citatio
- `web_search`
- `scrape_webpage`
-- `search_surfsense_docs`
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/__init__.py
index 414cc96f4..7234942b6 100644
--- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/__init__.py
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/__init__.py
@@ -1,11 +1,9 @@
-"""Research-stage tools: web search, scrape, and in-product doc search."""
+"""Research-stage tools: web search and scrape."""
from .scrape_webpage import create_scrape_webpage_tool
-from .search_surfsense_docs import create_search_surfsense_docs_tool
from .web_search import create_web_search_tool
__all__ = [
"create_scrape_webpage_tool",
- "create_search_surfsense_docs_tool",
"create_web_search_tool",
]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/index.py
index ea544a8da..d8abce46c 100644
--- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/index.py
+++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/index.py
@@ -9,7 +9,6 @@ from langchain_core.tools import BaseTool
from app.agents.new_chat.permissions import Ruleset
from .scrape_webpage import create_scrape_webpage_tool
-from .search_surfsense_docs import create_search_surfsense_docs_tool
from .web_search import create_web_search_tool
NAME = "research"
@@ -27,5 +26,4 @@ def load_tools(
available_connectors=d.get("available_connectors"),
),
create_scrape_webpage_tool(firecrawl_api_key=d.get("firecrawl_api_key")),
- create_search_surfsense_docs_tool(db_session=d["db_session"]),
]
diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/search_surfsense_docs.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/search_surfsense_docs.py
deleted file mode 100644
index ccc5c49e2..000000000
--- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/search_surfsense_docs.py
+++ /dev/null
@@ -1,145 +0,0 @@
-"""Semantic search over pre-indexed in-app documentation chunks for user how-to questions."""
-
-import asyncio
-import json
-
-from langchain_core.tools import tool
-from sqlalchemy import select
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from app.db import SurfsenseDocsChunk, SurfsenseDocsDocument
-from app.utils.document_converters import embed_text
-from app.utils.surfsense_docs import surfsense_docs_public_url
-
-
-def format_surfsense_docs_results(results: list[tuple]) -> str:
- """Format (chunk, document) rows as XML with ``doc-`` chunk IDs for citations and UI routing."""
- if not results:
- return "No relevant Surfsense documentation found for your query."
-
- # Group chunks by document
- grouped: dict[int, dict] = {}
- for chunk, doc in results:
- public_url = surfsense_docs_public_url(doc.source)
- if doc.id not in grouped:
- grouped[doc.id] = {
- "document_id": f"doc-{doc.id}",
- "document_type": "SURFSENSE_DOCS",
- "title": doc.title,
- "url": public_url,
- "metadata": {"source": doc.source, "public_url": public_url},
- "chunks": [],
- }
- grouped[doc.id]["chunks"].append(
- {
- "chunk_id": f"doc-{chunk.id}",
- "content": chunk.content,
- }
- )
-
- # Render XML matching format_documents_for_context structure
- parts: list[str] = []
- for g in grouped.values():
- metadata_json = json.dumps(g["metadata"], ensure_ascii=False)
-
- parts.append("")
- parts.append("")
- parts.append(f" {g['document_id']} ")
- parts.append(f" {g['document_type']} ")
- parts.append(f" ")
- parts.append(f" ")
- parts.append(f" ")
- parts.append(" ")
- parts.append("")
- parts.append("")
-
- for ch in g["chunks"]:
- parts.append(
- f" "
- )
-
- parts.append(" ")
- parts.append(" ")
- parts.append("")
-
- return "\n".join(parts).strip()
-
-
-async def search_surfsense_docs_async(
- query: str,
- db_session: AsyncSession,
- top_k: int = 10,
-) -> str:
- """
- Search Surfsense documentation using vector similarity.
-
- Args:
- query: The search query about Surfsense usage
- db_session: Database session for executing queries
- top_k: Number of results to return
-
- Returns:
- Formatted string with relevant documentation content
- """
- # Get embedding for the query
- query_embedding = await asyncio.to_thread(embed_text, query)
-
- # Vector similarity search on chunks, joining with documents
- stmt = (
- select(SurfsenseDocsChunk, SurfsenseDocsDocument)
- .join(
- SurfsenseDocsDocument,
- SurfsenseDocsChunk.document_id == SurfsenseDocsDocument.id,
- )
- .order_by(SurfsenseDocsChunk.embedding.op("<=>")(query_embedding))
- .limit(top_k)
- )
-
- result = await db_session.execute(stmt)
- rows = result.all()
-
- return format_surfsense_docs_results(rows)
-
-
-def create_search_surfsense_docs_tool(db_session: AsyncSession):
- """
- Factory function to create the search_surfsense_docs tool.
-
- Args:
- db_session: Database session for executing queries
-
- Returns:
- A configured tool function for searching Surfsense documentation
- """
-
- @tool
- async def search_surfsense_docs(query: str, top_k: int = 10) -> str:
- """
- Search Surfsense documentation for help with using the application.
-
- Use this tool when the user asks questions about:
- - How to use Surfsense features
- - Installation and setup instructions
- - Configuration options and settings
- - Troubleshooting common issues
- - Available connectors and integrations
- - Browser extension usage
- - API documentation
-
- This searches the official Surfsense documentation that was indexed
- at deployment time. It does NOT search the user's personal knowledge base.
-
- Args:
- query: The search query about Surfsense usage or features
- top_k: Number of documentation chunks to retrieve (default: 10)
-
- Returns:
- Relevant documentation content formatted with chunk IDs for citations
- """
- return await search_surfsense_docs_async(
- query=query,
- db_session=db_session,
- top_k=top_k,
- )
-
- return search_surfsense_docs
diff --git a/surfsense_backend/app/agents/new_chat/feature_flags.py b/surfsense_backend/app/agents/new_chat/feature_flags.py
index 3cea051ef..27188fac3 100644
--- a/surfsense_backend/app/agents/new_chat/feature_flags.py
+++ b/surfsense_backend/app/agents/new_chat/feature_flags.py
@@ -104,7 +104,7 @@ class AgentFeatureFlags:
# ``tools/google_drive``, ``tools/dropbox``, ``tools/onedrive``,
# ``tools/google_calendar``, ``tools/confluence``, ``tools/discord``,
# ``tools/teams``, ``tools/luma``, ``connected_accounts``,
- # ``update_memory``, ``search_surfsense_docs``) now acquire fresh
+ # ``update_memory``) now acquire fresh
# short-lived ``AsyncSession`` instances per call via
# :data:`async_session_maker`. The factory still accepts ``db_session``
# for registry compatibility but ``del``'s it immediately — see any
diff --git a/surfsense_backend/app/agents/new_chat/mention_resolver.py b/surfsense_backend/app/agents/new_chat/mention_resolver.py
index 6a025b947..f13dbc6ae 100644
--- a/surfsense_backend/app/agents/new_chat/mention_resolver.py
+++ b/surfsense_backend/app/agents/new_chat/mention_resolver.py
@@ -73,9 +73,8 @@ class ResolvedMentionSet:
``@Project Roadmap`` is never shadowed by a shorter prefix
``@Project``).
- ``mentioned_document_ids`` collapses doc + surfsense_doc chips into
- a single ordered, deduped list because the priority middleware
- treats them uniformly downstream — see
+ ``mentioned_document_ids`` is an ordered, deduped list consumed by
+ the priority middleware downstream — see
``KnowledgePriorityMiddleware._compute_priority_paths``.
"""
@@ -103,7 +102,6 @@ async def resolve_mentions(
search_space_id: int,
mentioned_documents: list[MentionedDocumentInfo] | None,
mentioned_document_ids: list[int] | None = None,
- mentioned_surfsense_doc_ids: list[int] | None = None,
mentioned_folder_ids: list[int] | None = None,
) -> ResolvedMentionSet:
"""Resolve every @-mention chip on a turn into virtual paths.
@@ -111,8 +109,7 @@ async def resolve_mentions(
The function takes both the ``mentioned_documents`` discriminated
list (chip metadata used for substitution + persistence) and the
parallel id arrays (``mentioned_document_ids``,
- ``mentioned_surfsense_doc_ids``, ``mentioned_folder_ids``) for two
- reasons:
+ ``mentioned_folder_ids``) for two reasons:
* Legacy clients that haven't migrated to the unified chip list
still send the id arrays — we treat the union as authoritative.
@@ -142,7 +139,6 @@ async def resolve_mentions(
dict.fromkeys(
[
*(mentioned_document_ids or []),
- *(mentioned_surfsense_doc_ids or []),
*chip_doc_ids,
]
)
diff --git a/surfsense_backend/app/agents/new_chat/prompts/base/citations_on.md b/surfsense_backend/app/agents/new_chat/prompts/base/citations_on.md
index 56291bf3e..3562ce66e 100644
--- a/surfsense_backend/app/agents/new_chat/prompts/base/citations_on.md
+++ b/surfsense_backend/app/agents/new_chat/prompts/base/citations_on.md
@@ -59,14 +59,13 @@ Do NOT cite document_id. Always use the chunk id.
- NEVER create your own citation format - use the exact chunk_id values from the documents in the [citation:chunk_id] format
- NEVER format citations as clickable links or as markdown links like "([citation:5](https://example.com))". Always use plain square brackets only
- NEVER make up chunk IDs if you are unsure about the chunk_id. It is better to omit the citation than to guess
-- Copy the EXACT chunk id from the XML - if it says ``, use [citation:doc-123]
+- Copy the EXACT chunk id from the XML - if it says ``, use [citation:5]
- If the chunk id is a URL like ``, use [citation:https://example.com/page]
CORRECT citation formats:
- [citation:5] (numeric chunk ID from knowledge base)
-- [citation:doc-123] (for Surfsense documentation chunks)
- [citation:https://example.com/article] (URL chunk ID from web search results)
- [citation:chunk_id1], [citation:chunk_id2], [citation:chunk_id3] (multiple citations)
diff --git a/surfsense_backend/app/agents/new_chat/prompts/base/kb_only_policy_private.md b/surfsense_backend/app/agents/new_chat/prompts/base/kb_only_policy_private.md
index 9cc767e7e..073b75fa5 100644
--- a/surfsense_backend/app/agents/new_chat/prompts/base/kb_only_policy_private.md
+++ b/surfsense_backend/app/agents/new_chat/prompts/base/kb_only_policy_private.md
@@ -7,7 +7,7 @@ CRITICAL RULE — KNOWLEDGE BASE FIRST, NEVER DEFAULT TO GENERAL KNOWLEDGE:
2. Ask the user: "Would you like me to answer from my general knowledge instead?"
3. ONLY provide a general-knowledge answer AFTER the user explicitly says yes.
- This policy does NOT apply to:
- * Casual conversation, greetings, or meta-questions about SurfSense itself (e.g., "what can you do?")
+ * Casual conversation, greetings, or meta-questions about SurfSense itself (e.g., "what can you do?"). For "how do I use SurfSense" / product-documentation questions, point the user to https://www.surfsense.com/docs.
* Formatting, summarization, or analysis of content already present in the conversation
* Following user instructions that are clearly task-oriented (e.g., "rewrite this in bullet points")
* Tool-usage actions like generating reports, podcasts, images, or scraping webpages
diff --git a/surfsense_backend/app/agents/new_chat/prompts/base/kb_only_policy_team.md b/surfsense_backend/app/agents/new_chat/prompts/base/kb_only_policy_team.md
index 1d806dbae..1a43ed490 100644
--- a/surfsense_backend/app/agents/new_chat/prompts/base/kb_only_policy_team.md
+++ b/surfsense_backend/app/agents/new_chat/prompts/base/kb_only_policy_team.md
@@ -7,7 +7,7 @@ CRITICAL RULE — KNOWLEDGE BASE FIRST, NEVER DEFAULT TO GENERAL KNOWLEDGE:
2. Ask: "Would you like me to answer from my general knowledge instead?"
3. ONLY provide a general-knowledge answer AFTER a team member explicitly says yes.
- This policy does NOT apply to:
- * Casual conversation, greetings, or meta-questions about SurfSense itself (e.g., "what can you do?")
+ * Casual conversation, greetings, or meta-questions about SurfSense itself (e.g., "what can you do?"). For "how do I use SurfSense" / product-documentation questions, point the user to https://www.surfsense.com/docs.
* Formatting, summarization, or analysis of content already present in the conversation
* Following user instructions that are clearly task-oriented (e.g., "rewrite this in bullet points")
* Tool-usage actions like generating reports, podcasts, images, or scraping webpages
diff --git a/surfsense_backend/app/agents/new_chat/prompts/base/tool_routing_private.md b/surfsense_backend/app/agents/new_chat/prompts/base/tool_routing_private.md
index b8bb069e2..9121de879 100644
--- a/surfsense_backend/app/agents/new_chat/prompts/base/tool_routing_private.md
+++ b/surfsense_backend/app/agents/new_chat/prompts/base/tool_routing_private.md
@@ -13,6 +13,7 @@ When to use which tool:
- Knowledge base content (Notion, GitHub, files, notes) → automatically searched
- Real-time public web data → call web_search
- Reading a specific webpage → call scrape_webpage
+- SurfSense product / how-to questions (setup, configuration, connectors, feature behavior) → point the user to the documentation: https://www.surfsense.com/docs
**`task` subagents (when to delegate):**
- **`linear_specialist`** — Linear-only investigations and tool use.
diff --git a/surfsense_backend/app/agents/new_chat/prompts/base/tool_routing_team.md b/surfsense_backend/app/agents/new_chat/prompts/base/tool_routing_team.md
index b081a2123..c5383be77 100644
--- a/surfsense_backend/app/agents/new_chat/prompts/base/tool_routing_team.md
+++ b/surfsense_backend/app/agents/new_chat/prompts/base/tool_routing_team.md
@@ -13,6 +13,7 @@ When to use which tool:
- Knowledge base content (Notion, GitHub, files, notes) → automatically searched
- Real-time public web data → call web_search
- Reading a specific webpage → call scrape_webpage
+- SurfSense product / how-to questions (setup, configuration, connectors, feature behavior) → point the user to the documentation: https://www.surfsense.com/docs
**`task` subagents (when to delegate):**
- **`linear_specialist`** — Linear-only investigations and tool use.
diff --git a/surfsense_backend/app/agents/new_chat/prompts/composer.py b/surfsense_backend/app/agents/new_chat/prompts/composer.py
index 42f8303e6..412665813 100644
--- a/surfsense_backend/app/agents/new_chat/prompts/composer.py
+++ b/surfsense_backend/app/agents/new_chat/prompts/composer.py
@@ -151,7 +151,6 @@ def _read_fragment(subpath: str) -> str:
# Ordered for reading flow: fundamentals first, then artifact generators,
# then memory at the end (mirrors the legacy ``_ALL_TOOL_NAMES_ORDERED``).
ALL_TOOL_NAMES_ORDERED: tuple[str, ...] = (
- "search_surfsense_docs",
"web_search",
"generate_podcast",
"generate_video_presentation",
diff --git a/surfsense_backend/app/agents/new_chat/prompts/examples/search_surfsense_docs.md b/surfsense_backend/app/agents/new_chat/prompts/examples/search_surfsense_docs.md
deleted file mode 100644
index b90f2b7a7..000000000
--- a/surfsense_backend/app/agents/new_chat/prompts/examples/search_surfsense_docs.md
+++ /dev/null
@@ -1,9 +0,0 @@
-
-- User: "How do I install SurfSense?"
- - Call: `search_surfsense_docs(query="installation setup")`
-- User: "What connectors does SurfSense support?"
- - Call: `search_surfsense_docs(query="available connectors integrations")`
-- User: "How do I set up the Notion connector?"
- - Call: `search_surfsense_docs(query="Notion connector setup configuration")`
-- User: "How do I use Docker to run SurfSense?"
- - Call: `search_surfsense_docs(query="Docker installation setup")`
diff --git a/surfsense_backend/app/agents/new_chat/prompts/tools/search_surfsense_docs.md b/surfsense_backend/app/agents/new_chat/prompts/tools/search_surfsense_docs.md
deleted file mode 100644
index 133717fec..000000000
--- a/surfsense_backend/app/agents/new_chat/prompts/tools/search_surfsense_docs.md
+++ /dev/null
@@ -1,7 +0,0 @@
-
-- search_surfsense_docs: Search the official SurfSense documentation.
- - Use this tool when the user asks anything about SurfSense itself (the application they are using).
- - Args:
- - query: The search query about SurfSense
- - top_k: Number of documentation chunks to retrieve (default: 10)
- - Returns: Documentation content with chunk IDs for citations (prefixed with 'doc-', e.g., [citation:doc-123])
diff --git a/surfsense_backend/app/agents/new_chat/skills/builtin/email-drafting/SKILL.md b/surfsense_backend/app/agents/new_chat/skills/builtin/email-drafting/SKILL.md
index 32e599e98..2dbc8ec43 100644
--- a/surfsense_backend/app/agents/new_chat/skills/builtin/email-drafting/SKILL.md
+++ b/surfsense_backend/app/agents/new_chat/skills/builtin/email-drafting/SKILL.md
@@ -1,7 +1,6 @@
---
name: email-drafting
description: Draft an email matching the user's voice, with structured intent and CTA
-allowed-tools: search_surfsense_docs
---
# Email drafting
diff --git a/surfsense_backend/app/agents/new_chat/skills/builtin/kb-research/SKILL.md b/surfsense_backend/app/agents/new_chat/skills/builtin/kb-research/SKILL.md
index c268278ab..0f0b5ffbb 100644
--- a/surfsense_backend/app/agents/new_chat/skills/builtin/kb-research/SKILL.md
+++ b/surfsense_backend/app/agents/new_chat/skills/builtin/kb-research/SKILL.md
@@ -1,7 +1,7 @@
---
name: kb-research
description: Structured approach to finding and synthesizing information from the user's knowledge base
-allowed-tools: search_surfsense_docs, scrape_webpage, read_file, ls_tree, grep, web_search
+allowed-tools: scrape_webpage, read_file, ls_tree, grep, web_search
---
# Knowledge-base research
diff --git a/surfsense_backend/app/agents/new_chat/skills/builtin/meeting-prep/SKILL.md b/surfsense_backend/app/agents/new_chat/skills/builtin/meeting-prep/SKILL.md
index 9657eb078..5a375fbde 100644
--- a/surfsense_backend/app/agents/new_chat/skills/builtin/meeting-prep/SKILL.md
+++ b/surfsense_backend/app/agents/new_chat/skills/builtin/meeting-prep/SKILL.md
@@ -1,7 +1,7 @@
---
name: meeting-prep
description: Pull together briefing materials before a scheduled meeting
-allowed-tools: search_surfsense_docs, web_search, scrape_webpage, read_file
+allowed-tools: web_search, scrape_webpage, read_file
---
# Meeting preparation
diff --git a/surfsense_backend/app/agents/new_chat/skills/builtin/report-writing/SKILL.md b/surfsense_backend/app/agents/new_chat/skills/builtin/report-writing/SKILL.md
index 17ac2f391..cfea9593f 100644
--- a/surfsense_backend/app/agents/new_chat/skills/builtin/report-writing/SKILL.md
+++ b/surfsense_backend/app/agents/new_chat/skills/builtin/report-writing/SKILL.md
@@ -1,7 +1,7 @@
---
name: report-writing
description: How to scope, draft, and revise a Markdown report artifact via generate_report
-allowed-tools: generate_report, search_surfsense_docs, read_file
+allowed-tools: generate_report, read_file
---
# Report writing
diff --git a/surfsense_backend/app/agents/new_chat/skills/builtin/slack-summary/SKILL.md b/surfsense_backend/app/agents/new_chat/skills/builtin/slack-summary/SKILL.md
index 33b9e72a2..1a4c3da9f 100644
--- a/surfsense_backend/app/agents/new_chat/skills/builtin/slack-summary/SKILL.md
+++ b/surfsense_backend/app/agents/new_chat/skills/builtin/slack-summary/SKILL.md
@@ -1,7 +1,6 @@
---
name: slack-summary
description: Distill a Slack channel or thread into actionable summary
-allowed-tools: search_surfsense_docs
---
# Slack summarization
diff --git a/surfsense_backend/app/agents/new_chat/subagents/config.py b/surfsense_backend/app/agents/new_chat/subagents/config.py
index b993d2b06..2cfd47441 100644
--- a/surfsense_backend/app/agents/new_chat/subagents/config.py
+++ b/surfsense_backend/app/agents/new_chat/subagents/config.py
@@ -46,7 +46,6 @@ logger = logging.getLogger(__name__)
# ``glob``, ``grep``) plus the SurfSense-side read tools.
EXPLORE_READ_TOOLS: frozenset[str] = frozenset(
{
- "search_surfsense_docs",
"web_search",
"scrape_webpage",
"read_file",
@@ -61,7 +60,6 @@ EXPLORE_READ_TOOLS: frozenset[str] = frozenset(
# is needed, the parent should hand off to ``explore`` first.
REPORT_WRITER_TOOLS: frozenset[str] = frozenset(
{
- "search_surfsense_docs",
"read_file",
"generate_report",
}
@@ -222,7 +220,6 @@ EXPLORE_SYSTEM_PROMPT = """You are the **explore** subagent for SurfSense.
Conduct read-only research across the user's knowledge base, the web, and any documents the parent agent has surfaced. Return a synthesized answer with explicit citations — never speculate beyond the sources you have actually inspected.
## Tools available
-- `search_surfsense_docs` — fast hybrid search over the user's knowledge base.
- `web_search` — only when the user's KB clearly does not contain the answer.
- `scrape_webpage` — to read a URL the user or the search results provided.
- `read_file`, `ls`, `glob`, `grep` — to inspect specific documents or trees the parent has flagged.
@@ -242,7 +239,7 @@ Produce a single high-quality report deliverable using `generate_report`. The pa
## Workflow
1. **Outline first.** Before calling `generate_report`, write a one-paragraph outline of the sections you plan to produce. Confirm the outline reflects the parent's instructions.
-2. **Source resolution.** Decide whether to call `search_surfsense_docs` and `read_file` for any final-checks, or whether the parent's earlier tool calls already cover the source set.
+2. **Source resolution.** Decide whether to call `read_file` for any final-checks, or whether the parent's earlier tool calls already cover the source set.
3. **One report.** Call `generate_report` exactly once with `source_strategy` chosen per the topic and chat history (see the `report-writing` skill).
4. **Confirm.** End with a one-sentence summary in your final message — never paste the report back into chat; the artifact card renders itself.
"""
diff --git a/surfsense_backend/app/agents/new_chat/tools/__init__.py b/surfsense_backend/app/agents/new_chat/tools/__init__.py
index bc444b0c0..4b5ae3706 100644
--- a/surfsense_backend/app/agents/new_chat/tools/__init__.py
+++ b/surfsense_backend/app/agents/new_chat/tools/__init__.py
@@ -5,7 +5,6 @@ This module contains all the tools available to the SurfSense agent.
To add a new tool, see the documentation in registry.py.
Available tools:
-- search_surfsense_docs: Search Surfsense documentation for usage help
- generate_podcast: Generate audio podcasts from content
- generate_video_presentation: Generate video presentations with slides and narration
- generate_image: Generate images from text descriptions using AI models
@@ -31,7 +30,6 @@ from .registry import (
get_tool_by_name,
)
from .scrape_webpage import create_scrape_webpage_tool
-from .search_surfsense_docs import create_search_surfsense_docs_tool
from .update_memory import create_update_memory_tool, create_update_team_memory_tool
from .video_presentation import create_generate_video_presentation_tool
@@ -47,7 +45,6 @@ __all__ = [
"create_generate_podcast_tool",
"create_generate_video_presentation_tool",
"create_scrape_webpage_tool",
- "create_search_surfsense_docs_tool",
"create_update_memory_tool",
"create_update_team_memory_tool",
"format_documents_for_context",
diff --git a/surfsense_backend/app/agents/new_chat/tools/registry.py b/surfsense_backend/app/agents/new_chat/tools/registry.py
index 8c263ca20..6f011e372 100644
--- a/surfsense_backend/app/agents/new_chat/tools/registry.py
+++ b/surfsense_backend/app/agents/new_chat/tools/registry.py
@@ -101,7 +101,6 @@ from .podcast import create_generate_podcast_tool
from .report import create_generate_report_tool
from .resume import create_generate_resume_tool
from .scrape_webpage import create_scrape_webpage_tool
-from .search_surfsense_docs import create_search_surfsense_docs_tool
from .teams import (
create_list_teams_channels_tool,
create_read_teams_messages_tool,
@@ -258,15 +257,6 @@ BUILTIN_TOOLS: list[ToolDefinition] = [
),
requires=[],
),
- # Surfsense documentation search tool
- ToolDefinition(
- name="search_surfsense_docs",
- description="Search Surfsense documentation for help with using the application",
- factory=lambda deps: create_search_surfsense_docs_tool(
- db_session=deps["db_session"],
- ),
- requires=["db_session"],
- ),
# =========================================================================
# SERVICE ACCOUNT DISCOVERY
# Generic tool for the LLM to discover connected accounts and resolve
diff --git a/surfsense_backend/app/agents/new_chat/tools/search_surfsense_docs.py b/surfsense_backend/app/agents/new_chat/tools/search_surfsense_docs.py
deleted file mode 100644
index d8a0efac7..000000000
--- a/surfsense_backend/app/agents/new_chat/tools/search_surfsense_docs.py
+++ /dev/null
@@ -1,174 +0,0 @@
-"""
-Surfsense documentation search tool.
-
-This tool allows the agent to search the pre-indexed Surfsense documentation
-to help users with questions about how to use the application.
-
-The documentation is indexed at deployment time from MDX files and stored
-in dedicated tables (surfsense_docs_documents, surfsense_docs_chunks).
-"""
-
-import asyncio
-import json
-
-from langchain_core.tools import tool
-from sqlalchemy import select
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from app.db import SurfsenseDocsChunk, SurfsenseDocsDocument, async_session_maker
-from app.utils.document_converters import embed_text
-from app.utils.surfsense_docs import surfsense_docs_public_url
-
-
-def format_surfsense_docs_results(results: list[tuple]) -> str:
- """
- Format search results into XML structure for the LLM context.
-
- Uses the same XML structure as format_documents_for_context from knowledge_base.py
- but with 'doc-' prefix on chunk IDs. This allows:
- - LLM to use consistent [citation:doc-XXX] format
- - Frontend to detect 'doc-' prefix and route to surfsense docs endpoint
-
- Args:
- results: List of (chunk, document) tuples from the database query
-
- Returns:
- Formatted XML string with documentation content and citation-ready chunks
- """
- if not results:
- return "No relevant Surfsense documentation found for your query."
-
- # Group chunks by document
- grouped: dict[int, dict] = {}
- for chunk, doc in results:
- public_url = surfsense_docs_public_url(doc.source)
- if doc.id not in grouped:
- grouped[doc.id] = {
- "document_id": f"doc-{doc.id}",
- "document_type": "SURFSENSE_DOCS",
- "title": doc.title,
- "url": public_url,
- "metadata": {"source": doc.source, "public_url": public_url},
- "chunks": [],
- }
- grouped[doc.id]["chunks"].append(
- {
- "chunk_id": f"doc-{chunk.id}",
- "content": chunk.content,
- }
- )
-
- # Render XML matching format_documents_for_context structure
- parts: list[str] = []
- for g in grouped.values():
- metadata_json = json.dumps(g["metadata"], ensure_ascii=False)
-
- parts.append("")
- parts.append("")
- parts.append(f" {g['document_id']} ")
- parts.append(f" {g['document_type']} ")
- parts.append(f" ")
- parts.append(f" ")
- parts.append(f" ")
- parts.append(" ")
- parts.append("")
- parts.append("")
-
- for ch in g["chunks"]:
- parts.append(
- f" "
- )
-
- parts.append(" ")
- parts.append(" ")
- parts.append("")
-
- return "\n".join(parts).strip()
-
-
-async def search_surfsense_docs_async(
- query: str,
- db_session: AsyncSession,
- top_k: int = 10,
-) -> str:
- """
- Search Surfsense documentation using vector similarity.
-
- Args:
- query: The search query about Surfsense usage
- db_session: Database session for executing queries
- top_k: Number of results to return
-
- Returns:
- Formatted string with relevant documentation content
- """
- # Get embedding for the query
- query_embedding = await asyncio.to_thread(embed_text, query)
-
- # Vector similarity search on chunks, joining with documents
- stmt = (
- select(SurfsenseDocsChunk, SurfsenseDocsDocument)
- .join(
- SurfsenseDocsDocument,
- SurfsenseDocsChunk.document_id == SurfsenseDocsDocument.id,
- )
- .order_by(SurfsenseDocsChunk.embedding.op("<=>")(query_embedding))
- .limit(top_k)
- )
-
- result = await db_session.execute(stmt)
- rows = result.all()
-
- return format_surfsense_docs_results(rows)
-
-
-def create_search_surfsense_docs_tool(db_session: AsyncSession):
- """
- Factory function to create the search_surfsense_docs tool.
-
- The tool acquires its own short-lived ``AsyncSession`` per call via
- :data:`async_session_maker` so the closure is safe to share across
- HTTP requests by the compiled-agent cache. Capturing a per-request
- session here would surface stale/closed sessions on cache hits.
-
- Args:
- db_session: Reserved for registry compatibility. Per-call sessions
- are opened via :data:`async_session_maker` inside the tool body.
-
- Returns:
- A configured tool function for searching Surfsense documentation
- """
- del db_session # per-call session — see docstring
-
- @tool
- async def search_surfsense_docs(query: str, top_k: int = 10) -> str:
- """
- Search Surfsense documentation for help with using the application.
-
- Use this tool when the user asks questions about:
- - How to use Surfsense features
- - Installation and setup instructions
- - Configuration options and settings
- - Troubleshooting common issues
- - Available connectors and integrations
- - Browser extension usage
- - API documentation
-
- This searches the official Surfsense documentation that was indexed
- at deployment time. It does NOT search the user's personal knowledge base.
-
- Args:
- query: The search query about Surfsense usage or features
- top_k: Number of documentation chunks to retrieve (default: 10)
-
- Returns:
- Relevant documentation content formatted with chunk IDs for citations
- """
- async with async_session_maker() as db_session:
- return await search_surfsense_docs_async(
- query=query,
- db_session=db_session,
- top_k=top_k,
- )
-
- return search_surfsense_docs
diff --git a/surfsense_backend/app/app.py b/surfsense_backend/app/app.py
index 43b0af7d2..223eb5a1b 100644
--- a/surfsense_backend/app/app.py
+++ b/surfsense_backend/app/app.py
@@ -43,7 +43,6 @@ from app.rate_limiter import get_real_client_ip, limiter
from app.routes import router as crud_router
from app.routes.auth_routes import router as auth_router
from app.schemas import UserCreate, UserRead, UserUpdate
-from app.tasks.surfsense_docs_indexer import seed_surfsense_docs
from app.users import SECRET, auth_backend, current_active_user, fastapi_users
from app.utils.perf import log_system_snapshot
@@ -576,13 +575,6 @@ async def lifespan(app: FastAPI):
initialize_llm_router()
initialize_image_gen_router()
initialize_vision_llm_router()
- try:
- await asyncio.wait_for(seed_surfsense_docs(), timeout=120)
- except TimeoutError:
- logging.getLogger(__name__).warning(
- "Surfsense docs seeding timed out after 120s — skipping. "
- "Docs will be indexed on the next restart."
- )
# Phase 1.7 — JIT warmup. Bounded so a stuck warmup never delays
# worker readiness. ``shield`` so Uvicorn cancelling startup
diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py
index fe2e53268..d6ee9ff88 100644
--- a/surfsense_backend/app/db.py
+++ b/surfsense_backend/app/db.py
@@ -1150,46 +1150,6 @@ class Chunk(BaseModel, TimestampMixin):
document = relationship("Document", back_populates="chunks")
-class SurfsenseDocsDocument(BaseModel, TimestampMixin):
- """
- Surfsense documentation storage.
- Indexed at migration time from MDX files.
- """
-
- __tablename__ = "surfsense_docs_documents"
-
- source = Column(
- String, nullable=False, unique=True, index=True
- ) # File path: "connectors/slack.mdx"
- title = Column(String, nullable=False)
- content = Column(Text, nullable=False)
- content_hash = Column(String, nullable=False, index=True) # For detecting changes
- embedding = Column(Vector(config.embedding_model_instance.dimension))
- updated_at = Column(TIMESTAMP(timezone=True), nullable=True, index=True)
-
- chunks = relationship(
- "SurfsenseDocsChunk",
- back_populates="document",
- cascade="all, delete-orphan",
- )
-
-
-class SurfsenseDocsChunk(BaseModel, TimestampMixin):
- """Chunk storage for Surfsense documentation."""
-
- __tablename__ = "surfsense_docs_chunks"
-
- content = Column(Text, nullable=False)
- embedding = Column(Vector(config.embedding_model_instance.dimension))
-
- document_id = Column(
- Integer,
- ForeignKey("surfsense_docs_documents.id", ondelete="CASCADE"),
- nullable=False,
- )
- document = relationship("SurfsenseDocsDocument", back_populates="chunks")
-
-
class Podcast(BaseModel, TimestampMixin):
"""Podcast model for storing generated podcasts."""
@@ -2680,11 +2640,6 @@ async def setup_indexes():
"CREATE INDEX IF NOT EXISTS idx_documents_search_space_updated ON documents (search_space_id, updated_at DESC NULLS LAST) INCLUDE (id, title, document_type)"
)
)
- await conn.execute(
- text(
- "CREATE INDEX IF NOT EXISTS idx_surfsense_docs_title_trgm ON surfsense_docs_documents USING gin (title gin_trgm_ops)"
- )
- )
async def create_db_and_tables():
diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py
index 48a095456..8373f13c3 100644
--- a/surfsense_backend/app/routes/__init__.py
+++ b/surfsense_backend/app/routes/__init__.py
@@ -55,7 +55,6 @@ from .search_source_connectors_routes import router as search_source_connectors_
from .search_spaces_routes import router as search_spaces_router
from .slack_add_connector_route import router as slack_add_connector_router
from .stripe_routes import router as stripe_router
-from .surfsense_docs_routes import router as surfsense_docs_router
from .team_memory_routes import router as team_memory_router
from .teams_add_connector_route import router as teams_add_connector_router
from .video_presentations_routes import router as video_presentations_router
@@ -108,7 +107,6 @@ router.include_router(new_llm_config_router) # LLM configs with prompt configur
router.include_router(model_list_router) # Dynamic model catalogue from OpenRouter
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
diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py
index fb4d5a049..63b7732a9 100644
--- a/surfsense_backend/app/routes/new_chat_routes.py
+++ b/surfsense_backend/app/routes/new_chat_routes.py
@@ -1785,7 +1785,6 @@ async def handle_new_chat(
user_id=str(user.id),
llm_config_id=llm_config_id,
mentioned_document_ids=request.mentioned_document_ids,
- mentioned_surfsense_doc_ids=request.mentioned_surfsense_doc_ids,
mentioned_folder_ids=request.mentioned_folder_ids,
mentioned_connector_ids=request.mentioned_connector_ids,
mentioned_connectors=mentioned_connectors_payload,
@@ -2278,7 +2277,6 @@ async def regenerate_response(
user_id=str(user.id),
llm_config_id=llm_config_id,
mentioned_document_ids=request.mentioned_document_ids,
- mentioned_surfsense_doc_ids=request.mentioned_surfsense_doc_ids,
mentioned_folder_ids=request.mentioned_folder_ids,
mentioned_connector_ids=request.mentioned_connector_ids,
mentioned_connectors=mentioned_connectors_payload,
diff --git a/surfsense_backend/app/routes/surfsense_docs_routes.py b/surfsense_backend/app/routes/surfsense_docs_routes.py
deleted file mode 100644
index 0d5428dec..000000000
--- a/surfsense_backend/app/routes/surfsense_docs_routes.py
+++ /dev/null
@@ -1,172 +0,0 @@
-"""
-Routes for Surfsense documentation.
-
-These endpoints support the citation system for Surfsense docs,
-allowing the frontend to fetch document details when a user clicks
-on a [citation:doc-XXX] link.
-"""
-
-from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy import func, select
-from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy.orm import selectinload
-
-from app.db import (
- SurfsenseDocsChunk,
- SurfsenseDocsDocument,
- User,
- get_async_session,
-)
-from app.schemas import PaginatedResponse
-from app.schemas.surfsense_docs import (
- SurfsenseDocsChunkRead,
- SurfsenseDocsDocumentRead,
- SurfsenseDocsDocumentWithChunksRead,
-)
-from app.users import current_active_user
-from app.utils.surfsense_docs import surfsense_docs_public_url
-
-router = APIRouter()
-
-
-@router.get(
- "/surfsense-docs/by-chunk/{chunk_id}",
- response_model=SurfsenseDocsDocumentWithChunksRead,
-)
-async def get_surfsense_doc_by_chunk_id(
- chunk_id: int,
- session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
-):
- """
- Retrieves a Surfsense documentation document based on a chunk ID.
-
- This endpoint is used by the frontend to resolve [citation:doc-XXX] links.
- """
- try:
- # Get the chunk
- chunk_result = await session.execute(
- select(SurfsenseDocsChunk).filter(SurfsenseDocsChunk.id == chunk_id)
- )
- chunk = chunk_result.scalars().first()
-
- if not chunk:
- raise HTTPException(
- status_code=404,
- detail=f"Surfsense docs chunk with id {chunk_id} not found",
- )
-
- # Get the associated document with all its chunks
- document_result = await session.execute(
- select(SurfsenseDocsDocument)
- .options(selectinload(SurfsenseDocsDocument.chunks))
- .filter(SurfsenseDocsDocument.id == chunk.document_id)
- )
- document = document_result.scalars().first()
-
- if not document:
- raise HTTPException(
- status_code=404,
- detail="Surfsense docs document not found",
- )
-
- # Sort chunks by ID
- sorted_chunks = sorted(document.chunks, key=lambda x: x.id)
-
- return SurfsenseDocsDocumentWithChunksRead(
- id=document.id,
- title=document.title,
- source=document.source,
- public_url=surfsense_docs_public_url(document.source),
- content=document.content,
- chunks=[
- SurfsenseDocsChunkRead(id=c.id, content=c.content)
- for c in sorted_chunks
- ],
- )
- except HTTPException:
- raise
- except Exception as e:
- raise HTTPException(
- status_code=500,
- detail=f"Failed to retrieve Surfsense documentation: {e!s}",
- ) from e
-
-
-@router.get(
- "/surfsense-docs",
- response_model=PaginatedResponse[SurfsenseDocsDocumentRead],
-)
-async def list_surfsense_docs(
- page: int = 0,
- page_size: int = 50,
- title: str | None = None,
- session: AsyncSession = Depends(get_async_session),
- user: User = Depends(current_active_user),
-):
- """
- List all Surfsense documentation documents.
-
- Args:
- page: Zero-based page index.
- page_size: Number of items per page (default: 50).
- title: Optional title filter (case-insensitive substring match).
- session: Database session (injected).
- user: Current authenticated user (injected).
-
- Returns:
- PaginatedResponse[SurfsenseDocsDocumentRead]: Paginated list of Surfsense docs.
- """
- try:
- # Base query
- query = select(SurfsenseDocsDocument)
- count_query = select(func.count()).select_from(SurfsenseDocsDocument)
-
- # Filter by title if provided
- if title and title.strip():
- query = query.filter(SurfsenseDocsDocument.title.ilike(f"%{title}%"))
- count_query = count_query.filter(
- SurfsenseDocsDocument.title.ilike(f"%{title}%")
- )
-
- # Get total count
- total_result = await session.execute(count_query)
- total = total_result.scalar() or 0
-
- # Calculate offset
- offset = page * page_size
-
- # Get paginated results
- result = await session.execute(
- query.order_by(SurfsenseDocsDocument.title).offset(offset).limit(page_size)
- )
- docs = result.scalars().all()
-
- # Convert to response format
- items = [
- SurfsenseDocsDocumentRead(
- id=doc.id,
- title=doc.title,
- source=doc.source,
- public_url=surfsense_docs_public_url(doc.source),
- content=doc.content,
- created_at=doc.created_at,
- updated_at=doc.updated_at,
- )
- for doc in docs
- ]
-
- has_more = (offset + len(items)) < total
-
- return PaginatedResponse(
- items=items,
- total=total,
- page=page,
- page_size=page_size,
- has_more=has_more,
- )
- except Exception as e:
- raise HTTPException(
- status_code=500,
- detail=f"Failed to list Surfsense documentation: {e!s}",
- ) from e
diff --git a/surfsense_backend/app/schemas/new_chat.py b/surfsense_backend/app/schemas/new_chat.py
index 8b49413c6..ab95f9b6b 100644
--- a/surfsense_backend/app/schemas/new_chat.py
+++ b/surfsense_backend/app/schemas/new_chat.py
@@ -239,9 +239,6 @@ class NewChatRequest(BaseModel):
mentioned_document_ids: list[int] | None = (
None # Optional document IDs mentioned with @ in the chat
)
- mentioned_surfsense_doc_ids: list[int] | None = (
- None # Optional SurfSense documentation IDs mentioned with @ in the chat
- )
mentioned_folder_ids: list[int] | None = Field(
default=None,
description=(
@@ -326,7 +323,6 @@ class RegenerateRequest(BaseModel):
None # New user query (for edit). None = reload with same query
)
mentioned_document_ids: list[int] | None = None
- mentioned_surfsense_doc_ids: list[int] | None = None
mentioned_folder_ids: list[int] | None = Field(
default=None,
description=(
diff --git a/surfsense_backend/app/schemas/surfsense_docs.py b/surfsense_backend/app/schemas/surfsense_docs.py
deleted file mode 100644
index 3adf25032..000000000
--- a/surfsense_backend/app/schemas/surfsense_docs.py
+++ /dev/null
@@ -1,43 +0,0 @@
-"""
-Schemas for Surfsense documentation.
-"""
-
-from datetime import datetime
-
-from pydantic import BaseModel, ConfigDict
-
-
-class SurfsenseDocsChunkRead(BaseModel):
- """Schema for a Surfsense docs chunk."""
-
- id: int
- content: str
-
- model_config = ConfigDict(from_attributes=True)
-
-
-class SurfsenseDocsDocumentRead(BaseModel):
- """Schema for a Surfsense docs document (without chunks)."""
-
- id: int
- title: str
- source: str
- public_url: str
- content: str
- created_at: datetime | None = None
- updated_at: datetime | None = None
-
- model_config = ConfigDict(from_attributes=True)
-
-
-class SurfsenseDocsDocumentWithChunksRead(BaseModel):
- """Schema for a Surfsense docs document with its chunks."""
-
- id: int
- title: str
- source: str
- public_url: str
- content: str
- chunks: list[SurfsenseDocsChunkRead]
-
- model_config = ConfigDict(from_attributes=True)
diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py
index 78f80c955..e150cf494 100644
--- a/surfsense_backend/app/tasks/chat/stream_new_chat.py
+++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py
@@ -25,7 +25,6 @@ from uuid import UUID
import anyio
from langchain_core.messages import HumanMessage
from sqlalchemy.future import select
-from sqlalchemy.orm import selectinload
from app.agents.multi_agent_chat import create_multi_agent_chat_deep_agent
from app.agents.new_chat.chat_deepagent import create_surfsense_deep_agent
@@ -55,7 +54,6 @@ from app.db import (
NewChatThread,
Report,
SearchSourceConnectorType,
- SurfsenseDocsDocument,
async_session_maker,
shielded_async_session,
)
@@ -77,7 +75,6 @@ from app.tasks.chat.streaming.helpers.interrupt_inspector import (
)
from app.utils.content_utils import bootstrap_history_from_db
from app.utils.perf import get_perf_logger, log_system_snapshot, trim_native_heap
-from app.utils.surfsense_docs import surfsense_docs_public_url
from app.utils.user_message_multimodal import build_human_message_content
_background_tasks: set[asyncio.Task] = set()
@@ -198,58 +195,6 @@ def _extract_chunk_parts(chunk: Any) -> dict[str, Any]:
return out
-def format_mentioned_surfsense_docs_as_context(
- documents: list[SurfsenseDocsDocument],
-) -> str:
- """Format mentioned SurfSense documentation as context for the agent."""
- if not documents:
- return ""
-
- context_parts = [""]
- context_parts.append(
- "The user has explicitly mentioned the following SurfSense documentation pages. "
- "These are official documentation about how to use SurfSense and should be used to answer questions about the application. "
- "Use [citation:CHUNK_ID] format for citations (e.g., [citation:doc-123])."
- )
-
- for doc in documents:
- public_url = surfsense_docs_public_url(doc.source)
- metadata_json = json.dumps(
- {"source": doc.source, "public_url": public_url}, ensure_ascii=False
- )
-
- context_parts.append("")
- context_parts.append("")
- context_parts.append(f" doc-{doc.id} ")
- context_parts.append(" SURFSENSE_DOCS ")
- context_parts.append(f" ")
- context_parts.append(f" ")
- context_parts.append(
- f" "
- )
- context_parts.append(" ")
- context_parts.append("")
- context_parts.append("")
-
- if hasattr(doc, "chunks") and doc.chunks:
- for chunk in doc.chunks:
- context_parts.append(
- f" "
- )
- else:
- context_parts.append(
- f" "
- )
-
- context_parts.append(" ")
- context_parts.append(" ")
- context_parts.append("")
-
- context_parts.append(" ")
-
- return "\n".join(context_parts)
-
-
def extract_todos_from_deepagents(command_output) -> dict:
"""
Extract todos from deepagents' TodoListMiddleware Command output.
@@ -837,7 +782,6 @@ async def stream_new_chat(
user_id: str | None = None,
llm_config_id: int = -1,
mentioned_document_ids: list[int] | None = None,
- mentioned_surfsense_doc_ids: list[int] | None = None,
mentioned_folder_ids: list[int] | None = None,
mentioned_connector_ids: list[int] | None = None,
mentioned_connectors: list[dict[str, Any]] | None = None,
@@ -869,7 +813,6 @@ async def stream_new_chat(
llm_config_id: The LLM configuration ID (default: -1 for first global config)
needs_history_bootstrap: If True, load message history from DB (for cloned chats)
mentioned_document_ids: Optional list of document IDs mentioned with @ in the chat
- mentioned_surfsense_doc_ids: Optional list of SurfSense doc IDs mentioned with @ in the chat
mentioned_folder_ids: Optional list of knowledge-base folder IDs mentioned with @ (cloud mode)
checkpoint_id: Optional checkpoint ID to rewind/fork from (for edit/reload operations)
@@ -1295,19 +1238,7 @@ async def stream_new_chat(
# Mentioned KB documents are now handled by KnowledgeBaseSearchMiddleware
# which merges them into the scoped filesystem with full document
- # structure. Only SurfSense docs and report context are inlined here.
-
- # Fetch mentioned SurfSense docs if any
- mentioned_surfsense_docs: list[SurfsenseDocsDocument] = []
- if mentioned_surfsense_doc_ids:
- result = await session.execute(
- select(SurfsenseDocsDocument)
- .options(selectinload(SurfsenseDocsDocument.chunks))
- .filter(
- SurfsenseDocsDocument.id.in_(mentioned_surfsense_doc_ids),
- )
- )
- mentioned_surfsense_docs = list(result.scalars().all())
+ # structure. Only report context is inlined here.
# Fetch the most recent report(s) in this thread so the LLM can
# easily find report_id for versioning decisions, instead of
@@ -1341,10 +1272,7 @@ async def stream_new_chat(
agent_user_query = user_query
accepted_folder_ids: list[int] = []
if fs_mode == FilesystemMode.CLOUD.value and (
- mentioned_document_ids
- or mentioned_surfsense_doc_ids
- or mentioned_folder_ids
- or mentioned_documents
+ mentioned_document_ids or mentioned_folder_ids or mentioned_documents
):
from app.schemas.new_chat import (
MentionedDocumentInfo as _MentionedDocumentInfo,
@@ -1370,23 +1298,17 @@ async def stream_new_chat(
search_space_id=search_space_id,
mentioned_documents=chip_objs,
mentioned_document_ids=mentioned_document_ids,
- mentioned_surfsense_doc_ids=mentioned_surfsense_doc_ids,
mentioned_folder_ids=mentioned_folder_ids,
)
agent_user_query = substitute_in_text(user_query, resolved.token_to_path)
accepted_folder_ids = resolved.mentioned_folder_ids
- # Format the user query with context (SurfSense docs + reports only).
+ # Format the user query with context (reports only).
# Uses ``agent_user_query`` so the LLM sees backtick-wrapped paths
# instead of bare ``@title`` tokens.
final_query = agent_user_query
context_parts = []
- if mentioned_surfsense_docs:
- context_parts.append(
- format_mentioned_surfsense_docs_as_context(mentioned_surfsense_docs)
- )
-
if mentioned_connectors:
connector_lines = []
for connector in mentioned_connectors:
@@ -1617,12 +1539,8 @@ async def stream_new_chat(
stream_result.content_builder = AssistantContentBuilder()
# Initial thinking step - analyzing the request
- if mentioned_surfsense_docs:
- initial_title = "Analyzing referenced content"
- action_verb = "Analyzing"
- else:
- initial_title = "Understanding your request"
- action_verb = "Processing"
+ initial_title = "Understanding your request"
+ action_verb = "Processing"
processing_parts = []
if user_query.strip():
@@ -1633,18 +1551,6 @@ async def stream_new_chat(
else:
processing_parts.append("(message)")
- if mentioned_surfsense_docs:
- doc_names = []
- for doc in mentioned_surfsense_docs:
- title = doc.title
- if len(title) > 30:
- title = title[:27] + "..."
- doc_names.append(title)
- if len(doc_names) == 1:
- processing_parts.append(f"[{doc_names[0]}]")
- else:
- processing_parts.append(f"[{len(doc_names)} docs]")
-
initial_items = [f"{action_verb}: {' '.join(processing_parts)}"]
initial_step_id = "thinking-1"
@@ -1664,10 +1570,10 @@ async def stream_new_chat(
items=initial_items,
)
- # These ORM objects (with eagerly-loaded chunks) can be very large.
- # They're only needed to build context strings already copied into
- # final_query / langchain_messages — release them before streaming.
- del mentioned_surfsense_docs, recent_reports
+ # These ORM objects can be large. They're only needed to build context
+ # strings already copied into final_query / langchain_messages —
+ # release them before streaming.
+ del recent_reports
del langchain_messages, final_query
# Check if this is the first assistant response so we can generate
diff --git a/surfsense_backend/app/tasks/chat/streaming/context/__init__.py b/surfsense_backend/app/tasks/chat/streaming/context/__init__.py
index f858a6c06..4cf58d76f 100644
--- a/surfsense_backend/app/tasks/chat/streaming/context/__init__.py
+++ b/surfsense_backend/app/tasks/chat/streaming/context/__init__.py
@@ -1,15 +1,11 @@
-"""Pre-agent context shaping: mentioned-doc rendering and todos extraction."""
+"""Pre-agent context shaping: todos extraction."""
from __future__ import annotations
from app.tasks.chat.streaming.context.deepagents_todos import (
extract_todos_from_deepagents,
)
-from app.tasks.chat.streaming.context.mentioned_docs import (
- format_mentioned_surfsense_docs_as_context,
-)
__all__ = [
"extract_todos_from_deepagents",
- "format_mentioned_surfsense_docs_as_context",
]
diff --git a/surfsense_backend/app/tasks/chat/streaming/context/mentioned_docs.py b/surfsense_backend/app/tasks/chat/streaming/context/mentioned_docs.py
deleted file mode 100644
index e02e98d34..000000000
--- a/surfsense_backend/app/tasks/chat/streaming/context/mentioned_docs.py
+++ /dev/null
@@ -1,58 +0,0 @@
-"""Render user-mentioned SurfSense docs as XML context for the agent."""
-
-from __future__ import annotations
-
-import json
-
-from app.db import SurfsenseDocsDocument
-from app.utils.surfsense_docs import surfsense_docs_public_url
-
-
-def format_mentioned_surfsense_docs_as_context(
- documents: list[SurfsenseDocsDocument],
-) -> str:
- if not documents:
- return ""
-
- context_parts = [""]
- context_parts.append(
- "The user has explicitly mentioned the following SurfSense documentation pages. "
- "These are official documentation about how to use SurfSense and should be used to answer questions about the application. "
- "Use [citation:CHUNK_ID] format for citations (e.g., [citation:doc-123])."
- )
-
- for doc in documents:
- public_url = surfsense_docs_public_url(doc.source)
- metadata_json = json.dumps(
- {"source": doc.source, "public_url": public_url}, ensure_ascii=False
- )
-
- context_parts.append("")
- context_parts.append("")
- context_parts.append(f" doc-{doc.id} ")
- context_parts.append(" SURFSENSE_DOCS ")
- context_parts.append(f" ")
- context_parts.append(f" ")
- context_parts.append(
- f" "
- )
- context_parts.append(" ")
- context_parts.append("")
- context_parts.append("")
-
- if hasattr(doc, "chunks") and doc.chunks:
- for chunk in doc.chunks:
- context_parts.append(
- f" "
- )
- else:
- context_parts.append(
- f" "
- )
-
- context_parts.append(" ")
- context_parts.append(" ")
- context_parts.append("")
-
- context_parts.append(" ")
- return "\n".join(context_parts)
diff --git a/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/initial_thinking_step.py b/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/initial_thinking_step.py
index c860e517e..e727200eb 100644
--- a/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/initial_thinking_step.py
+++ b/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/initial_thinking_step.py
@@ -1,8 +1,8 @@
"""Build and emit the first ``thinking-1`` step for a new-chat turn.
The step title and "Processing X" items are derived from what the user sent
-(text snippet, image count, mentioned doc titles) so the FE can render a
-meaningful placeholder while the agent stream warms up.
+(text snippet, image count) so the FE can render a meaningful placeholder
+while the agent stream warms up.
``thinking-1`` is the canonical id for this step — every subsequent
``thinking-N`` produced by ``stream_agent_events`` folds into the same
@@ -15,7 +15,6 @@ from collections.abc import Iterator
from dataclasses import dataclass
from typing import Any
-from app.db import SurfsenseDocsDocument
from app.services.new_streaming_service import VercelStreamingService
@@ -37,14 +36,9 @@ def build_initial_thinking_step(
*,
user_query: str,
user_image_data_urls: list[str] | None,
- mentioned_surfsense_docs: list[SurfsenseDocsDocument],
) -> InitialThinkingStep:
- if mentioned_surfsense_docs:
- title = "Analyzing referenced content"
- action_verb = "Analyzing"
- else:
- title = "Understanding your request"
- action_verb = "Processing"
+ title = "Understanding your request"
+ action_verb = "Processing"
processing_parts: list[str] = []
if user_query.strip():
@@ -55,18 +49,6 @@ def build_initial_thinking_step(
else:
processing_parts.append("(message)")
- if mentioned_surfsense_docs:
- doc_names: list[str] = []
- for doc in mentioned_surfsense_docs:
- t = doc.title
- if len(t) > 30:
- t = t[:27] + "..."
- doc_names.append(t)
- if len(doc_names) == 1:
- processing_parts.append(f"[{doc_names[0]}]")
- else:
- processing_parts.append(f"[{len(doc_names)} docs]")
-
items = [f"{action_verb}: {' '.join(processing_parts)}"]
return InitialThinkingStep(step_id="thinking-1", title=title, items=items)
diff --git a/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/input_state.py b/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/input_state.py
index f508571b0..0c6704bd1 100644
--- a/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/input_state.py
+++ b/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/input_state.py
@@ -5,20 +5,17 @@ Pipeline:
1. **History bootstrap** — only for cloned chats with no LangGraph checkpoint
yet; flips the per-thread ``needs_history_bootstrap`` flag back to False
once the rows are loaded.
- 2. **Mentioned SurfSense docs** — eager-load chunks so the formatter has the
- full content without a second roundtrip.
- 3. **Recent reports** — top 3 by id desc with non-null content, so the LLM
+ 2. **Recent reports** — top 3 by id desc with non-null content, so the LLM
can resolve ``report_id`` for versioning without spelunking history.
- 4. **@-mention resolve** (cloud mode) — substitute ``@title`` tokens in the
+ 3. **@-mention resolve** (cloud mode) — substitute ``@title`` tokens in the
query with canonical ``\`/documents/...\``` paths the LLM expects.
- 5. **Context block render** — XML-wrap surfsense docs + reports, prepend to
- the rewritten query, optionally prefix with display name for SEARCH_SPACE
+ 4. **Context block render** — XML-wrap recent reports, prepend to the
+ rewritten query, optionally prefix with display name for SEARCH_SPACE
visibility.
- 6. **HumanMessage** — multimodal content if images are attached.
+ 5. **HumanMessage** — multimodal content if images are attached.
Returns the assembled ``input_state`` dict plus side-channel data the
-orchestrator needs downstream (``accepted_folder_ids`` for runtime context;
-``mentioned_surfsense_docs`` for the initial thinking step).
+orchestrator needs downstream (``accepted_folder_ids`` for runtime context).
"""
from __future__ import annotations
@@ -30,7 +27,6 @@ from typing import Any
from langchain_core.messages import HumanMessage
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
-from sqlalchemy.orm import selectinload
from app.agents.new_chat.filesystem_selection import FilesystemMode
from app.agents.new_chat.mention_resolver import resolve_mentions, substitute_in_text
@@ -38,10 +34,6 @@ from app.db import (
ChatVisibility,
NewChatThread,
Report,
- SurfsenseDocsDocument,
-)
-from app.tasks.chat.streaming.context.mentioned_docs import (
- format_mentioned_surfsense_docs_as_context,
)
from app.utils.content_utils import bootstrap_history_from_db
from app.utils.user_message_multimodal import build_human_message_content
@@ -55,13 +47,10 @@ class NewChatInputState:
``input_state`` is fed straight to the agent. ``accepted_folder_ids``
feeds the runtime context (the resolver may have dropped some chips).
- ``mentioned_surfsense_docs`` is consumed by the initial thinking-step
- builder for the FE placeholder before the agent stream starts.
"""
input_state: dict[str, Any]
accepted_folder_ids: list[int]
- mentioned_surfsense_docs: list[SurfsenseDocsDocument]
async def build_new_chat_input_state(
@@ -72,7 +61,6 @@ async def build_new_chat_input_state(
user_query: str,
user_image_data_urls: list[str] | None,
mentioned_document_ids: list[int] | None,
- mentioned_surfsense_doc_ids: list[int] | None,
mentioned_folder_ids: list[int] | None,
mentioned_documents: list[dict[str, Any]] | None,
needs_history_bootstrap: bool,
@@ -96,15 +84,6 @@ async def build_new_chat_input_state(
thread.needs_history_bootstrap = False
await session.commit()
- mentioned_surfsense_docs: list[SurfsenseDocsDocument] = []
- if mentioned_surfsense_doc_ids:
- result = await session.execute(
- select(SurfsenseDocsDocument)
- .options(selectinload(SurfsenseDocsDocument.chunks))
- .filter(SurfsenseDocsDocument.id.in_(mentioned_surfsense_doc_ids))
- )
- mentioned_surfsense_docs = list(result.scalars().all())
-
# Top 3 reports keyed by id desc (newest first) with content present,
# surfaced inline so the LLM resolves ``report_id`` for versioning without
# digging through conversation history.
@@ -125,14 +104,12 @@ async def build_new_chat_input_state(
user_query=user_query,
filesystem_mode=filesystem_mode,
mentioned_document_ids=mentioned_document_ids,
- mentioned_surfsense_doc_ids=mentioned_surfsense_doc_ids,
mentioned_folder_ids=mentioned_folder_ids,
mentioned_documents=mentioned_documents,
)
final_query = _render_query_with_context(
agent_user_query=agent_user_query,
- mentioned_surfsense_docs=mentioned_surfsense_docs,
recent_reports=recent_reports,
)
@@ -154,7 +131,6 @@ async def build_new_chat_input_state(
return NewChatInputState(
input_state=input_state,
accepted_folder_ids=accepted_folder_ids,
- mentioned_surfsense_docs=mentioned_surfsense_docs,
)
@@ -165,7 +141,6 @@ async def _resolve_mentions_for_query(
user_query: str,
filesystem_mode: str,
mentioned_document_ids: list[int] | None,
- mentioned_surfsense_doc_ids: list[int] | None,
mentioned_folder_ids: list[int] | None,
mentioned_documents: list[dict[str, Any]] | None,
) -> tuple[str, list[int]]:
@@ -187,10 +162,7 @@ async def _resolve_mentions_for_query(
accepted_folder_ids: list[int] = []
has_any_mention = bool(
- mentioned_document_ids
- or mentioned_surfsense_doc_ids
- or mentioned_folder_ids
- or mentioned_documents
+ mentioned_document_ids or mentioned_folder_ids or mentioned_documents
)
if filesystem_mode != FilesystemMode.CLOUD.value or not has_any_mention:
return agent_user_query, accepted_folder_ids
@@ -214,7 +186,6 @@ async def _resolve_mentions_for_query(
search_space_id=search_space_id,
mentioned_documents=chip_objs,
mentioned_document_ids=mentioned_document_ids,
- mentioned_surfsense_doc_ids=mentioned_surfsense_doc_ids,
mentioned_folder_ids=mentioned_folder_ids,
)
agent_user_query = substitute_in_text(user_query, resolved.token_to_path)
@@ -225,17 +196,11 @@ async def _resolve_mentions_for_query(
def _render_query_with_context(
*,
agent_user_query: str,
- mentioned_surfsense_docs: list[SurfsenseDocsDocument],
recent_reports: list[Report],
) -> str:
- """Prepend surfsense-docs + recent-reports XML blocks to the user query."""
+ """Prepend recent-reports XML block to the user query."""
context_parts: list[str] = []
- if mentioned_surfsense_docs:
- context_parts.append(
- format_mentioned_surfsense_docs_as_context(mentioned_surfsense_docs)
- )
-
if recent_reports:
report_lines: list[str] = []
for r in recent_reports:
diff --git a/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/orchestrator.py b/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/orchestrator.py
index 6d0853502..1892320d3 100644
--- a/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/orchestrator.py
+++ b/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/orchestrator.py
@@ -123,7 +123,6 @@ async def stream_new_chat(
user_id: str | None = None,
llm_config_id: int = -1,
mentioned_document_ids: list[int] | None = None,
- mentioned_surfsense_doc_ids: list[int] | None = None,
mentioned_folder_ids: list[int] | None = None,
mentioned_documents: list[dict[str, Any]] | None = None,
checkpoint_id: str | None = None,
@@ -435,7 +434,6 @@ async def stream_new_chat(
user_query=user_query,
user_image_data_urls=user_image_data_urls,
mentioned_document_ids=mentioned_document_ids,
- mentioned_surfsense_doc_ids=mentioned_surfsense_doc_ids,
mentioned_folder_ids=mentioned_folder_ids,
mentioned_documents=mentioned_documents,
needs_history_bootstrap=needs_history_bootstrap,
@@ -447,7 +445,6 @@ async def stream_new_chat(
)
input_state = assembled.input_state
accepted_folder_ids = assembled.accepted_folder_ids
- mentioned_surfsense_docs = assembled.mentioned_surfsense_docs
_perf_log.info(
"[stream_new_chat] History bootstrap + doc/report queries in %.3fs",
time.perf_counter() - _t0,
@@ -560,7 +557,6 @@ async def stream_new_chat(
initial_step = build_initial_thinking_step(
user_query=user_query,
user_image_data_urls=user_image_data_urls,
- mentioned_surfsense_docs=mentioned_surfsense_docs,
)
for sse in iter_initial_thinking_step_frame(
initial_step,
@@ -575,7 +571,7 @@ async def stream_new_chat(
# Drop the heavy ORM objects + the container that holds them so they
# aren't retained for the entire streaming duration. ``input_state``
# already carries the langchain_messages list independently.
- del assembled, mentioned_surfsense_docs
+ del assembled
title_task = spawn_title_task(
chat_id=chat_id,
diff --git a/surfsense_backend/app/tasks/surfsense_docs_indexer.py b/surfsense_backend/app/tasks/surfsense_docs_indexer.py
deleted file mode 100644
index db88c8700..000000000
--- a/surfsense_backend/app/tasks/surfsense_docs_indexer.py
+++ /dev/null
@@ -1,249 +0,0 @@
-"""
-Surfsense documentation indexer.
-Indexes MDX documentation files at startup.
-"""
-
-import hashlib
-import logging
-import re
-from datetime import UTC, datetime
-from pathlib import Path
-
-from sqlalchemy import delete as sa_delete, select
-from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy.orm import selectinload
-from sqlalchemy.orm.attributes import set_committed_value
-
-from app.config import config
-from app.db import SurfsenseDocsChunk, SurfsenseDocsDocument, async_session_maker
-from app.utils.document_converters import embed_text
-
-logger = logging.getLogger(__name__)
-
-
-async def _safe_set_docs_chunks(
- session: AsyncSession, document: SurfsenseDocsDocument, chunks: list
-) -> None:
- """safe_set_chunks variant for the SurfsenseDocsDocument/Chunk models."""
- if document.id is not None:
- await session.execute(
- sa_delete(SurfsenseDocsChunk).where(
- SurfsenseDocsChunk.document_id == document.id
- )
- )
- for chunk in chunks:
- chunk.document_id = document.id
-
- set_committed_value(document, "chunks", chunks)
- session.add_all(chunks)
-
-
-# Path to docs relative to project root
-DOCS_DIR = (
- Path(__file__).resolve().parent.parent.parent.parent
- / "surfsense_web"
- / "content"
- / "docs"
-)
-
-
-def parse_mdx_frontmatter(content: str) -> tuple[str, str]:
- """
- Parse MDX file to extract frontmatter title and content.
-
- Args:
- content: Raw MDX file content
-
- Returns:
- Tuple of (title, content_without_frontmatter)
- """
- # Match frontmatter between --- markers
- frontmatter_pattern = r"^---\s*\n(.*?)\n---\s*\n"
- match = re.match(frontmatter_pattern, content, re.DOTALL)
-
- if match:
- frontmatter = match.group(1)
- content_without_frontmatter = content[match.end() :]
-
- # Extract title from frontmatter
- title_match = re.search(r"^title:\s*(.+)$", frontmatter, re.MULTILINE)
- title = title_match.group(1).strip() if title_match else "Untitled"
-
- # Remove quotes if present
- title = title.strip("\"'")
-
- return title, content_without_frontmatter.strip()
-
- return "Untitled", content.strip()
-
-
-def get_all_mdx_files() -> list[Path]:
- """
- Get all MDX files from the docs directory.
-
- Returns:
- List of Path objects for each MDX file
- """
- if not DOCS_DIR.exists():
- logger.warning(f"Docs directory not found: {DOCS_DIR}")
- return []
-
- return list(DOCS_DIR.rglob("*.mdx"))
-
-
-def generate_surfsense_docs_content_hash(content: str) -> str:
- """Generate SHA-256 hash for Surfsense docs content."""
- return hashlib.sha256(content.encode("utf-8")).hexdigest()
-
-
-def create_surfsense_docs_chunks(content: str) -> list[SurfsenseDocsChunk]:
- """
- Create chunks from Surfsense documentation content.
-
- Args:
- content: Document content to chunk
-
- Returns:
- List of SurfsenseDocsChunk objects with embeddings
- """
- return [
- SurfsenseDocsChunk(
- content=chunk.text,
- embedding=embed_text(chunk.text),
- )
- for chunk in config.chunker_instance.chunk(content)
- ]
-
-
-async def index_surfsense_docs(session: AsyncSession) -> tuple[int, int, int, int]:
- """
- Index all Surfsense documentation files.
-
- Args:
- session: SQLAlchemy async session
-
- Returns:
- Tuple of (created, updated, skipped, deleted) counts
- """
- created = 0
- updated = 0
- skipped = 0
- deleted = 0
-
- # Get all existing docs from database
- existing_docs_result = await session.execute(
- select(SurfsenseDocsDocument).options(
- selectinload(SurfsenseDocsDocument.chunks)
- )
- )
- existing_docs = {doc.source: doc for doc in existing_docs_result.scalars().all()}
-
- # Track which sources we've processed
- processed_sources = set()
-
- # Get all MDX files
- mdx_files = get_all_mdx_files()
- logger.info(f"Found {len(mdx_files)} MDX files to index")
-
- for mdx_file in mdx_files:
- try:
- source = str(mdx_file.relative_to(DOCS_DIR))
- processed_sources.add(source)
-
- # Read file content
- raw_content = mdx_file.read_text(encoding="utf-8")
- title, content = parse_mdx_frontmatter(raw_content)
- content_hash = generate_surfsense_docs_content_hash(raw_content)
-
- if source in existing_docs:
- existing_doc = existing_docs[source]
-
- # Check if content changed
- if existing_doc.content_hash == content_hash:
- logger.debug(f"Skipping unchanged: {source}")
- skipped += 1
- continue
-
- # Content changed - update document
- logger.info(f"Updating changed document: {source}")
-
- # Create new chunks
- chunks = create_surfsense_docs_chunks(content)
-
- # Update document fields
- existing_doc.title = title
- existing_doc.content = content
- existing_doc.content_hash = content_hash
- existing_doc.embedding = embed_text(content)
- await _safe_set_docs_chunks(session, existing_doc, chunks)
- existing_doc.updated_at = datetime.now(UTC)
-
- updated += 1
- else:
- # New document - create it
- logger.info(f"Creating new document: {source}")
-
- chunks = create_surfsense_docs_chunks(content)
-
- document = SurfsenseDocsDocument(
- source=source,
- title=title,
- content=content,
- content_hash=content_hash,
- embedding=embed_text(content),
- chunks=chunks,
- updated_at=datetime.now(UTC),
- )
-
- session.add(document)
- created += 1
-
- except Exception as e:
- logger.error(f"Error processing {mdx_file}: {e}", exc_info=True)
- continue
-
- # Delete documents for removed files
- for source, doc in existing_docs.items():
- if source not in processed_sources:
- logger.info(f"Deleting removed document: {source}")
- await session.delete(doc)
- deleted += 1
-
- # Commit all changes
- await session.commit()
-
- logger.info(
- f"Indexing complete: {created} created, {updated} updated, "
- f"{skipped} skipped, {deleted} deleted"
- )
-
- return created, updated, skipped, deleted
-
-
-async def seed_surfsense_docs() -> tuple[int, int, int, int]:
- """
- Seed Surfsense documentation into the database.
-
- This function indexes all MDX files from the docs directory.
- It handles creating, updating, and deleting docs based on content changes.
-
- Returns:
- Tuple of (created, updated, skipped, deleted) counts
- Returns (0, 0, 0, 0) if an error occurs
- """
- logger.info("Starting Surfsense docs indexing...")
-
- try:
- async with async_session_maker() as session:
- created, updated, skipped, deleted = await index_surfsense_docs(session)
-
- logger.info(
- f"Surfsense docs indexing complete: "
- f"created={created}, updated={updated}, skipped={skipped}, deleted={deleted}"
- )
-
- return created, updated, skipped, deleted
-
- except Exception as e:
- logger.error(f"Failed to seed Surfsense docs: {e}", exc_info=True)
- return 0, 0, 0, 0
diff --git a/surfsense_backend/app/utils/surfsense_docs.py b/surfsense_backend/app/utils/surfsense_docs.py
deleted file mode 100644
index 9a6ab11a9..000000000
--- a/surfsense_backend/app/utils/surfsense_docs.py
+++ /dev/null
@@ -1,13 +0,0 @@
-"""Utilities for SurfSense's built-in documentation index."""
-
-from pathlib import PurePosixPath
-
-DOCS_PUBLIC_ROOT = PurePosixPath("/docs")
-
-
-def surfsense_docs_public_url(source: str) -> str:
- """Return the public docs route for an indexed documentation source path."""
- docs_path = PurePosixPath(source).with_suffix("")
- if docs_path.name == "index":
- docs_path = docs_path.parent
- return (DOCS_PUBLIC_ROOT / docs_path).as_posix()
diff --git a/surfsense_backend/scripts/seed_surfsense_docs.py b/surfsense_backend/scripts/seed_surfsense_docs.py
deleted file mode 100644
index 68899c2aa..000000000
--- a/surfsense_backend/scripts/seed_surfsense_docs.py
+++ /dev/null
@@ -1,40 +0,0 @@
-#!/usr/bin/env python
-"""
-Seed Surfsense documentation into the database.
-
-CLI wrapper for the seed_surfsense_docs function.
-Can be run manually for debugging or re-indexing.
-
-Usage:
- python scripts/seed_surfsense_docs.py
-"""
-
-import asyncio
-import sys
-from pathlib import Path
-
-# Add the parent directory to the path so we can import app modules
-sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
-
-from app.tasks.surfsense_docs_indexer import seed_surfsense_docs
-
-
-def main():
- """CLI entry point for seeding Surfsense docs."""
- print("=" * 50)
- print(" Surfsense Documentation Seeding")
- print("=" * 50)
-
- created, updated, skipped, deleted = asyncio.run(seed_surfsense_docs())
-
- print()
- print("Results:")
- print(f" Created: {created}")
- print(f" Updated: {updated}")
- print(f" Skipped: {skipped}")
- print(f" Deleted: {deleted}")
- print("=" * 50)
-
-
-if __name__ == "__main__":
- main()
diff --git a/surfsense_backend/tests/unit/agents/new_chat/test_default_permissions_layering.py b/surfsense_backend/tests/unit/agents/new_chat/test_default_permissions_layering.py
index ac6b5d95c..2f222e148 100644
--- a/surfsense_backend/tests/unit/agents/new_chat/test_default_permissions_layering.py
+++ b/surfsense_backend/tests/unit/agents/new_chat/test_default_permissions_layering.py
@@ -60,7 +60,6 @@ class TestReadOnlyToolsAllowed:
"glob",
"web_search",
"scrape_webpage",
- "search_surfsense_docs",
"get_connected_accounts",
"write_todos",
"task",
diff --git a/surfsense_backend/tests/unit/agents/new_chat/test_specialized_subagents.py b/surfsense_backend/tests/unit/agents/new_chat/test_specialized_subagents.py
index 3035cc8e0..3c7fe5336 100644
--- a/surfsense_backend/tests/unit/agents/new_chat/test_specialized_subagents.py
+++ b/surfsense_backend/tests/unit/agents/new_chat/test_specialized_subagents.py
@@ -22,12 +22,6 @@ from app.agents.new_chat.subagents.config import (
# ---------------------------------------------------------------------------
-@tool
-def search_surfsense_docs(query: str) -> str:
- """Search the user's KB."""
- return ""
-
-
@tool
def web_search(query: str) -> str:
"""Search the public web."""
@@ -95,7 +89,6 @@ def generate_report(topic: str) -> str:
ALL_TOOLS = [
- search_surfsense_docs,
web_search,
scrape_webpage,
read_file,
@@ -161,7 +154,7 @@ class TestReportWriterSubagent:
names = {t.name for t in spec["tools"]} # type: ignore[index]
assert names == REPORT_WRITER_TOOLS & {t.name for t in ALL_TOOLS}
assert "generate_report" in names
- assert "search_surfsense_docs" in names
+ assert "read_file" in names
def test_deny_rules_block_writes_but_allow_generate_report(self) -> None:
spec = build_report_writer_subagent(tools=ALL_TOOLS)
@@ -272,9 +265,9 @@ class TestFilterToolsWarningSuppression:
# Allowed set asks for two registry tools (one present, one
# not) plus a bunch of middleware-provided names.
_filter_tools(
- [search_surfsense_docs],
+ [web_search],
allowed_names={
- "search_surfsense_docs",
+ "web_search",
"scrape_webpage", # legitimately missing → should warn
"read_file", # mw-provided → suppressed
"ls",
@@ -322,7 +315,6 @@ class TestDenyPatternsCoverage:
def test_deny_patterns_do_not_match_safe_read_tools(self) -> None:
canonical_reads = [
- "search_surfsense_docs",
"read_file",
"ls_tree",
"grep",
diff --git a/surfsense_backend/tests/unit/tasks/chat/streaming/test_parallel_refactor_parity.py b/surfsense_backend/tests/unit/tasks/chat/streaming/test_parallel_refactor_parity.py
index ff4ca30df..e014bb911 100644
--- a/surfsense_backend/tests/unit/tasks/chat/streaming/test_parallel_refactor_parity.py
+++ b/surfsense_backend/tests/unit/tasks/chat/streaming/test_parallel_refactor_parity.py
@@ -25,7 +25,6 @@ from __future__ import annotations
import asyncio
import inspect
-from dataclasses import dataclass
from typing import Any
from unittest.mock import AsyncMock, patch
@@ -140,45 +139,28 @@ def test_orchestrators_are_async_generator_functions() -> None:
# ------------------------------------------------------------ initial thinking
-@dataclass
-class _FakeSurfsenseDoc:
- """Stand-in for ``SurfsenseDocsDocument`` with just the field we read."""
-
- title: str
-
-
@pytest.mark.parametrize(
- "user_query, image_urls, docs, expected_title, expected_action",
+ "user_query, image_urls, expected_title, expected_action",
[
- ("hello world", None, [], "Understanding your request", "Processing"),
+ ("hello world", None, "Understanding your request", "Processing"),
(
"",
["data:image/png;base64,AAA"],
- [],
"Understanding your request",
"Processing",
),
- ("", None, [], "Understanding your request", "Processing"),
- (
- "doc question",
- None,
- [_FakeSurfsenseDoc(title="My Doc")],
- "Analyzing referenced content",
- "Analyzing",
- ),
+ ("", None, "Understanding your request", "Processing"),
],
)
def test_initial_thinking_step_branches(
user_query: str,
image_urls: list[str] | None,
- docs: list[Any],
expected_title: str,
expected_action: str,
) -> None:
step = build_initial_thinking_step(
user_query=user_query,
user_image_data_urls=image_urls,
- mentioned_surfsense_docs=docs, # type: ignore[arg-type]
)
assert step.step_id == "thinking-1"
assert step.title == expected_title
@@ -191,7 +173,6 @@ def test_initial_thinking_step_truncates_long_query() -> None:
step = build_initial_thinking_step(
user_query=long_query,
user_image_data_urls=None,
- mentioned_surfsense_docs=[],
)
# 80-char truncation + ellipsis, sandwiched after "Processing: ".
assert "..." in step.items[0]
@@ -200,16 +181,6 @@ def test_initial_thinking_step_truncates_long_query() -> None:
assert payload.startswith("x" * 80) and payload.endswith("...")
-def test_initial_thinking_step_collapses_many_doc_names() -> None:
- docs = [_FakeSurfsenseDoc(title=f"Doc {i}") for i in range(5)]
- step = build_initial_thinking_step(
- user_query="q",
- user_image_data_urls=None,
- mentioned_surfsense_docs=docs, # type: ignore[arg-type]
- )
- assert "[5 docs]" in step.items[0]
-
-
# ------------------------------------------------------------ capability gate
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/mention-task-input.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/mention-task-input.tsx
index 312454056..c0651a90b 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/mention-task-input.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/mention-task-input.tsx
@@ -89,10 +89,10 @@ function removeFirstToken(text: string, token: string): string {
/**
* Task input that reuses the chat ``@`` mention experience -- the same
- * ``InlineMentionEditor`` + ``DocumentMentionPicker`` as the composer, minus
- * SurfSense product docs. The editor is the source of truth while mounted;
- * ``onChange`` reports both the plain text (chips rendered as ``@Title``) and
- * the structured mention list so the builder can persist IDs for the run.
+ * ``InlineMentionEditor`` + ``DocumentMentionPicker`` as the composer. The
+ * editor is the source of truth while mounted; ``onChange`` reports both the
+ * plain text (chips rendered as ``@Title``) and the structured mention list
+ * so the builder can persist IDs for the run.
*/
export function MentionTaskInput({
searchSpaceId,
@@ -233,7 +233,6 @@ export function MentionTaskInput({
documentsApiService.searchDocumentTitles({ queryParams: prefetchParams }),
staleTime: 60 * 1000,
});
-
- queryClient.prefetchQuery({
- queryKey: ["surfsense-docs-mention", "", false],
- queryFn: () =>
- documentsApiService.getSurfsenseDocs({
- queryParams: { page: 0, page_size: 20 },
- }),
- staleTime: 3 * 60 * 1000,
- });
}, [searchSpaceId, queryClient]);
// Handle scroll to comment from URL query params (e.g., from inbox item click)
@@ -949,7 +940,6 @@ export default function NewChatPage() {
trackChatMessageSent(searchSpaceId, currentThreadId, {
hasAttachments: userImages.length > 0,
hasMentionedDocuments:
- mentionedDocumentIds.surfsense_doc_ids.length > 0 ||
mentionedDocumentIds.document_ids.length > 0 ||
mentionedDocumentIds.folder_ids.length > 0 ||
mentionedDocumentIds.connector_ids.length > 0,
@@ -1027,12 +1017,11 @@ export default function NewChatPage() {
// Get mentioned document IDs for context (separate fields for backend)
const hasDocumentIds = mentionedDocumentIds.document_ids.length > 0;
- const hasSurfsenseDocIds = mentionedDocumentIds.surfsense_doc_ids.length > 0;
const hasFolderIds = mentionedDocumentIds.folder_ids.length > 0;
const hasConnectorIds = mentionedDocumentIds.connector_ids.length > 0;
// Clear mentioned documents after capturing them
- if (hasDocumentIds || hasSurfsenseDocIds || hasFolderIds || hasConnectorIds) {
+ if (hasDocumentIds || hasFolderIds || hasConnectorIds) {
setMentionedDocuments([]);
}
@@ -1054,9 +1043,6 @@ export default function NewChatPage() {
mentioned_document_ids: hasDocumentIds
? mentionedDocumentIds.document_ids
: undefined,
- mentioned_surfsense_doc_ids: hasSurfsenseDocIds
- ? mentionedDocumentIds.surfsense_doc_ids
- : undefined,
mentioned_folder_ids: hasFolderIds ? mentionedDocumentIds.folder_ids : undefined,
mentioned_connector_ids: hasConnectorIds
? mentionedDocumentIds.connector_ids
@@ -1947,18 +1933,14 @@ export default function NewChatPage() {
const selection = await getAgentFilesystemSelection(searchSpaceId, {
localFilesystemEnabled,
});
- // Partition the source mentions back into doc/surfsense_doc/folder
- // id buckets so the regenerate route can pass them to
- // ``stream_new_chat`` and the priority middleware sees the
- // same ``[USER-MENTIONED]`` priority entries the original
- // turn did. Without this partition the regenerate flow
- // silently dropped the agent's mention awareness — same
- // architectural bug we fixed on the new-chat path.
- const regenerateSurfsenseDocIds = sourceMentionedDocs
- .filter((d) => d.kind === "doc" && d.document_type === "SURFSENSE_DOCS")
- .map((d) => d.id);
+ // Partition the source mentions back into doc/folder id buckets
+ // so the regenerate route can pass them to ``stream_new_chat``
+ // and the priority middleware sees the same ``[USER-MENTIONED]``
+ // priority entries the original turn did. Without this partition
+ // the regenerate flow silently dropped the agent's mention
+ // awareness — same architectural bug we fixed on the new-chat path.
const regenerateDocIds = sourceMentionedDocs
- .filter((d) => d.kind === "doc" && d.document_type !== "SURFSENSE_DOCS")
+ .filter((d) => d.kind === "doc")
.map((d) => d.id);
const regenerateFolderIds = sourceMentionedDocs
.filter((d) => d.kind === "folder")
@@ -1973,8 +1955,6 @@ export default function NewChatPage() {
client_platform: selection.client_platform,
local_filesystem_mounts: selection.local_filesystem_mounts,
mentioned_document_ids: regenerateDocIds.length > 0 ? regenerateDocIds : undefined,
- mentioned_surfsense_doc_ids:
- regenerateSurfsenseDocIds.length > 0 ? regenerateSurfsenseDocIds : undefined,
mentioned_folder_ids: regenerateFolderIds.length > 0 ? regenerateFolderIds : undefined,
mentioned_connector_ids:
regenerateConnectors.length > 0 ? regenerateConnectors.map((d) => d.id) : undefined,
diff --git a/surfsense_web/atoms/chat/mentioned-documents.atom.ts b/surfsense_web/atoms/chat/mentioned-documents.atom.ts
index 25d1e397a..cf1bd8bcf 100644
--- a/surfsense_web/atoms/chat/mentioned-documents.atom.ts
+++ b/surfsense_web/atoms/chat/mentioned-documents.atom.ts
@@ -102,10 +102,7 @@ export const mentionedDocumentIdsAtom = atom((get) => {
const folders = deduped.filter((m) => m.kind === "folder");
const connectors = deduped.filter((m) => m.kind === "connector");
return {
- surfsense_doc_ids: docs
- .filter((doc) => doc.document_type === "SURFSENSE_DOCS")
- .map((doc) => doc.id),
- document_ids: docs.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id),
+ document_ids: docs.map((doc) => doc.id),
folder_ids: folders.map((f) => f.id),
connector_ids: connectors.map((c) => c.id),
connectors: connectors.map((c) => ({
diff --git a/surfsense_web/components/assistant-ui/inline-citation.tsx b/surfsense_web/components/assistant-ui/inline-citation.tsx
index a788c0ce6..cbf3c82d6 100644
--- a/surfsense_web/components/assistant-ui/inline-citation.tsx
+++ b/surfsense_web/components/assistant-ui/inline-citation.tsx
@@ -1,16 +1,13 @@
"use client";
-import { useQuery } from "@tanstack/react-query";
import { useSetAtom } from "jotai";
-import { ExternalLink, FileText } from "lucide-react";
-import dynamic from "next/dynamic";
+import { FileText } from "lucide-react";
import type { FC } from "react";
import { useState } from "react";
import { openCitationPanelAtom } from "@/atoms/citation/citation-panel.atom";
import { useCitationMetadata } from "@/components/assistant-ui/citation-metadata-context";
import { CitationPanelContent } from "@/components/citation-panel/citation-panel";
import { Citation } from "@/components/tool-ui/citation";
-import { CitationHoverPopover } from "@/components/tool-ui/citation/citation-hover-popover";
import { Button } from "@/components/ui/button";
import {
Drawer,
@@ -19,21 +16,8 @@ import {
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
-import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useMediaQuery } from "@/hooks/use-media-query";
-import { documentsApiService } from "@/lib/apis/documents-api.service";
-import { cacheKeys } from "@/lib/query-client/cache-keys";
-
-// Lazily load MarkdownViewer here to break the static import cycle:
-// `markdown-viewer.tsx` → `citation-renderer.tsx` → `inline-citation.tsx`
-// would otherwise pull `markdown-viewer.tsx` back in at module-init time.
-// Only `SurfsenseDocCitation` (popover body) ever renders this viewer, so
-// the lazy boundary is invisible to most call paths.
-const MarkdownViewer = dynamic(
- () => import("@/components/markdown-viewer").then((m) => m.MarkdownViewer),
- { ssr: false, loading: () => }
-);
interface InlineCitationProps {
chunkId: number;
@@ -41,9 +25,7 @@ interface InlineCitationProps {
}
/**
- * Inline citation badge for knowledge-base chunks (numeric chunk IDs) and
- * Surfsense documentation chunks (`isDocsChunk`). Negative chunk IDs render as
- * a static "doc" pill (anonymous/synthetic uploads).
+ * Inline citation badge for knowledge-base chunks (numeric chunk IDs).
*
* Numeric KB chunks: clicking opens the citation panel in the right
* sidebar (alongside the chat — does not replace it). The panel shows
@@ -51,12 +33,13 @@ interface InlineCitationProps {
* `chunk_window`), with the cited one highlighted and an option to
* expand the window or jump into the full document via the editor panel.
*
- * Surfsense docs chunks: rendered as a hover-controlled shadcn Popover that
- * lazily fetches and previews the cited chunk inline, since those docs aren't
- * indexed into the user's search space and have no tab to open.
+ * Negative chunk IDs and legacy SurfSense-docs chunks (`isDocsChunk`) render
+ * as a static, non-interactive "doc" pill. The SurfSense product-docs feature
+ * was removed, so those markers are inert (no fetch, no preview) — they only
+ * survive in old persisted messages.
*/
export const InlineCitation: FC = ({ chunkId, isDocsChunk = false }) => {
- if (chunkId < 0) {
+ if (chunkId < 0 || isDocsChunk) {
return (
@@ -68,15 +51,11 @@ export const InlineCitation: FC = ({ chunkId, isDocsChunk =
doc
- Uploaded document
+ {isDocsChunk ? "Documentation reference" : "Uploaded document"}
);
}
- if (isDocsChunk) {
- return ;
- }
-
return ;
};
@@ -127,128 +106,6 @@ const NumericChunkCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
);
};
-const SurfsenseDocCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
- const isTouchLike = useMediaQuery("(hover: none), (pointer: coarse)");
- const [mobilePreviewOpen, setMobilePreviewOpen] = useState(false);
- const docQuery = useSurfsenseDocPreviewQuery(chunkId, mobilePreviewOpen);
-
- const handleMobileClick = () => {
- setMobilePreviewOpen(true);
- };
-
- return (
- <>
- (
-
-
- doc
-
- )}
- >
-
-
-
-
-
-
- Surfsense documentation
-
-
-
-
- >
- );
-};
-
-function useSurfsenseDocPreviewQuery(chunkId: number, enabled = true) {
- return useQuery({
- queryKey: cacheKeys.documents.byChunk(`doc-${chunkId}`),
- queryFn: () => documentsApiService.getSurfsenseDocByChunk(chunkId),
- staleTime: 5 * 60 * 1000,
- enabled,
- });
-}
-
-type SurfsenseDocPreviewQuery = ReturnType;
-
-const SurfsenseDocPreview: FC<{ chunkId: number }> = ({ chunkId }) => {
- const query = useSurfsenseDocPreviewQuery(chunkId);
-
- return ;
-};
-
-const SurfsenseDocPreviewContent: FC<{
- chunkId: number;
- query: SurfsenseDocPreviewQuery;
- contentClassName?: string;
-}> = ({ chunkId, query, contentClassName = "max-h-72" }) => {
- const { data, isLoading, error } = query;
-
- const citedChunk = data?.chunks.find((c) => c.id === chunkId) ?? data?.chunks[0];
-
- return (
- <>
-
-
-
{data?.title ?? "Surfsense documentation"}
-
Chunk #{chunkId}
-
- {data?.public_url && (
-
-
- Open
-
- )}
-
-
- {isLoading && (
-
-
- Loading…
-
- )}
- {error && (
-
- {error instanceof Error ? error.message : "Failed to load chunk"}
-
- )}
- {!isLoading && !error && citedChunk?.content && (
-
- )}
- {!isLoading && !error && !citedChunk?.content && (
-
No content available.
- )}
-
- >
- );
-};
-
import { tryGetHostname } from "@/lib/url";
interface UrlCitationProps {
diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx
index 9abcfbb49..0336ffd35 100644
--- a/surfsense_web/components/assistant-ui/thread.tsx
+++ b/surfsense_web/components/assistant-ui/thread.tsx
@@ -1593,7 +1593,7 @@ interface ToolGroup {
const TOOL_GROUPS: ToolGroup[] = [
{
label: "Research",
- tools: ["search_surfsense_docs", "scrape_webpage"],
+ tools: ["scrape_webpage"],
},
{
label: "Generate",
diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx
index 757ee2fc2..881fbe2b0 100644
--- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx
@@ -90,7 +90,6 @@ const DesktopLocalTabContent = dynamic(
);
const NON_DELETABLE_DOCUMENT_TYPES: readonly string[] = [
- "SURFSENSE_DOCS",
"USER_MEMORY",
"TEAM_MEMORY",
];
diff --git a/surfsense_web/components/new-chat/document-mention-picker.tsx b/surfsense_web/components/new-chat/document-mention-picker.tsx
index 8e3fd4ca8..769327e1e 100644
--- a/surfsense_web/components/new-chat/document-mention-picker.tsx
+++ b/surfsense_web/components/new-chat/document-mention-picker.tsx
@@ -3,14 +3,7 @@
import { useQuery as useZeroQuery } from "@rocicorp/zero/react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
-import {
- BookOpen,
- ChevronLeft,
- ChevronRight,
- Files,
- Folder as FolderIcon,
- Unplug,
-} from "lucide-react";
+import { ChevronLeft, ChevronRight, Files, Folder as FolderIcon, Unplug } from "lucide-react";
import {
Fragment,
forwardRef,
@@ -57,13 +50,6 @@ interface DocumentMentionPickerProps {
onDone: () => void;
initialSelectedDocuments?: MentionedDocumentInfo[];
externalSearch?: string;
- /**
- * Whether to surface the "SurfSense Docs" (product documentation) branch
- * and include those docs in search results. Defaults to ``true`` so the
- * chat composer is unchanged; callers like the automation task input pass
- * ``false`` to reference only the user's own knowledge base + connectors.
- */
- includeSurfsenseDocs?: boolean;
}
const PAGE_SIZE = 20;
@@ -74,7 +60,6 @@ const RECENTS_STORAGE_PREFIX = "surfsense:composer-mention-recents:v1:";
type BrowseView =
| { kind: "root" }
- | { kind: "surfsense-docs" }
| { kind: "files-folders" }
| { kind: "connectors" }
| { kind: "connector-type"; connectorType: string; title: string };
@@ -241,7 +226,6 @@ export const DocumentMentionPicker = forwardRef<
onDone,
initialSelectedDocuments = [],
externalSearch = "",
- includeSurfsenseDocs = true,
},
ref
) {
@@ -298,15 +282,6 @@ export const DocumentMentionPicker = forwardRef<
[searchSpaceId, debouncedSearch, isSearchValid]
);
- const surfsenseDocsQueryParams = useMemo(() => {
- const params: { page: number; page_size: number; title?: string } = {
- page: 0,
- page_size: PAGE_SIZE,
- };
- if (isSearchValid) params.title = debouncedSearch.trim();
- return params;
- }, [debouncedSearch, isSearchValid]);
-
const { data: titleSearchResults, isLoading: isTitleSearchLoading } = useQuery({
queryKey: ["document-titles", titleSearchParams],
queryFn: ({ signal }) =>
@@ -316,15 +291,6 @@ export const DocumentMentionPicker = forwardRef<
placeholderData: keepPreviousData,
});
- const { data: surfsenseDocs, isLoading: isSurfsenseDocsLoading } = useQuery({
- queryKey: ["surfsense-docs-mention", debouncedSearch, isSearchValid],
- queryFn: ({ signal }) =>
- documentsApiService.getSurfsenseDocs({ queryParams: surfsenseDocsQueryParams }, signal),
- staleTime: 3 * 60 * 1000,
- enabled: includeSurfsenseDocs && (!hasSearch || isSearchValid),
- placeholderData: keepPreviousData,
- });
-
const filterBySearchTerm = useCallback(
(docs: Pick[]) => {
if (!isSearchValid) return docs;
@@ -338,23 +304,13 @@ export const DocumentMentionPicker = forwardRef<
if (currentPage !== 0) return;
const combinedDocs: Pick[] = [];
- if (includeSurfsenseDocs && surfsenseDocs?.items) {
- for (const doc of surfsenseDocs.items) {
- combinedDocs.push({
- id: doc.id,
- title: doc.title,
- document_type: "SURFSENSE_DOCS",
- });
- }
- }
-
if (titleSearchResults?.items) {
combinedDocs.push(...titleSearchResults.items);
setHasMore(titleSearchResults.has_more);
}
setAccumulatedDocuments(filterBySearchTerm(combinedDocs));
- }, [titleSearchResults, surfsenseDocs, currentPage, filterBySearchTerm, includeSurfsenseDocs]);
+ }, [titleSearchResults, currentPage, filterBySearchTerm]);
const loadNextPage = useCallback(async () => {
if (isLoadingMore || !hasMore) return;
@@ -391,14 +347,6 @@ export const DocumentMentionPicker = forwardRef<
return accumulatedDocuments.filter((doc) => doc.title.toLowerCase().includes(searchLower));
}, [accumulatedDocuments, deferredSearch, isSingleCharSearch]);
- const surfsenseDocsList = useMemo(
- () => actualDocuments.filter((doc) => doc.document_type === "SURFSENSE_DOCS"),
- [actualDocuments]
- );
- const userDocsList = useMemo(
- () => actualDocuments.filter((doc) => doc.document_type !== "SURFSENSE_DOCS"),
- [actualDocuments]
- );
const folderMentions = useMemo(() => {
const all = (zeroFolders ?? []).map((f) => makeFolderMention({ id: f.id, title: f.name }));
if (!hasSearch) return all;
@@ -463,7 +411,6 @@ export const DocumentMentionPicker = forwardRef<
() => new Set(initialSelectedDocuments.map((d) => getMentionDocKey(d))),
[initialSelectedDocuments]
);
- const showSurfsenseDocsRoot = includeSurfsenseDocs && surfsenseDocsList.length > 0;
const selectMention = useCallback(
(mention: MentionedDocumentInfo) => {
@@ -487,16 +434,6 @@ export const DocumentMentionPicker = forwardRef<
const rootNodes = useMemo[]>(() => {
const nodes: ComposerSuggestionNode[] = [...recentRootNodes];
- if (showSurfsenseDocsRoot) {
- nodes.push({
- id: "surfsense-docs",
- label: "SurfSense Docs",
- subtitle: "Browse product documentation",
- icon: ,
- type: "branch",
- value: { kind: "view", view: { kind: "surfsense-docs" } },
- });
- }
nodes.push(
{
id: "files-folders",
@@ -519,7 +456,7 @@ export const DocumentMentionPicker = forwardRef<
}
);
return nodes;
- }, [activeConnectors.length, recentRootNodes, showSurfsenseDocsRoot]);
+ }, [activeConnectors.length, recentRootNodes]);
const searchNodes = useMemo[]>(() => {
const searchLower = (isSingleCharSearch ? deferredSearch : debouncedSearch)
@@ -582,19 +519,6 @@ export const DocumentMentionPicker = forwardRef<
const browseNodes = useMemo[]>(() => {
if (view.kind === "root") return rootNodes;
- if (view.kind === "surfsense-docs") {
- return surfsenseDocsList.map((doc) => {
- const mention = makeDocMention(doc);
- return {
- id: getMentionDocKey(mention),
- label: doc.title,
- icon: getConnectorIcon(doc.document_type, "size-4"),
- type: "item" as const,
- disabled: selectedKeys.has(getMentionDocKey(mention)),
- value: { kind: "mention" as const, mention },
- };
- });
- }
if (view.kind === "files-folders") {
const folders = folderMentions.map((mention) => ({
id: getMentionDocKey(mention),
@@ -605,7 +529,7 @@ export const DocumentMentionPicker = forwardRef<
disabled: selectedKeys.has(getMentionDocKey(mention)),
value: { kind: "mention" as const, mention },
}));
- const docs = userDocsList.map((doc) => {
+ const docs = actualDocuments.map((doc) => {
const mention = makeDocMention(doc);
return {
id: getMentionDocKey(mention),
@@ -652,13 +576,12 @@ export const DocumentMentionPicker = forwardRef<
};
});
}, [
+ actualDocuments,
activeConnectors,
connectorTypeEntries,
folderMentions,
rootNodes,
selectedKeys,
- surfsenseDocsList,
- userDocsList,
view,
]);
@@ -708,27 +631,23 @@ export const DocumentMentionPicker = forwardRef<
const isRootBrowseView = !hasSearch && view.kind === "root";
const isVisibleViewLoading = hasSearch
- ? isTitleSearchLoading || isSurfsenseDocsLoading || isConnectorsLoading
- : view.kind === "surfsense-docs"
- ? isSurfsenseDocsLoading
- : view.kind === "files-folders"
- ? isTitleSearchLoading
- : view.kind === "connectors" || view.kind === "connector-type"
- ? isConnectorsLoading
- : false;
+ ? isTitleSearchLoading || isConnectorsLoading
+ : view.kind === "files-folders"
+ ? isTitleSearchLoading
+ : view.kind === "connectors" || view.kind === "connector-type"
+ ? isConnectorsLoading
+ : false;
const actualLoading =
isVisibleViewLoading && !isSingleCharSearch && visibleNodes.length === 0 && !isRootBrowseView;
const title =
hasSearch || view.kind === "root"
? null
- : view.kind === "surfsense-docs"
- ? "SurfSense Docs"
- : view.kind === "files-folders"
- ? "Files & Folders"
- : view.kind === "connectors"
- ? "Connectors"
- : view.title;
+ : view.kind === "files-folders"
+ ? "Files & Folders"
+ : view.kind === "connectors"
+ ? "Connectors"
+ : view.title;
return (
;
case "EXTENSION":
return ;
- case "SURFSENSE_DOCS":
- return ;
case "USER_MEMORY":
case "TEAM_MEMORY":
return ;
diff --git a/surfsense_web/contracts/enums/toolIcons.tsx b/surfsense_web/contracts/enums/toolIcons.tsx
index 668cb51cd..494c0eaee 100644
--- a/surfsense_web/contracts/enums/toolIcons.tsx
+++ b/surfsense_web/contracts/enums/toolIcons.tsx
@@ -1,5 +1,4 @@
import {
- BookOpen,
Brain,
Calendar,
FileEdit,
@@ -47,7 +46,6 @@ const TOOL_ICONS: Record = {
// Web / search
scrape_webpage: ScanLine,
web_search: Globe,
- search_surfsense_docs: BookOpen,
// Automations
create_automation: Workflow,
// Memory
@@ -152,7 +150,6 @@ const TOOL_DISPLAY_NAMES: Record = {
// Web / search
scrape_webpage: "Read webpage",
web_search: "Search the web",
- search_surfsense_docs: "Search knowledge base",
// Automations
create_automation: "Create automation",
// Memory
diff --git a/surfsense_web/contracts/types/document.types.ts b/surfsense_web/contracts/types/document.types.ts
index ccc15fa62..82c6cbdaf 100644
--- a/surfsense_web/contracts/types/document.types.ts
+++ b/surfsense_web/contracts/types/document.types.ts
@@ -27,7 +27,6 @@ export const documentTypeEnum = z.enum([
"CIRCLEBACK",
"OBSIDIAN_CONNECTOR",
"LOCAL_FOLDER_FILE",
- "SURFSENSE_DOCS",
"NOTE",
"USER_MEMORY",
"TEAM_MEMORY",
@@ -77,27 +76,6 @@ export const documentWithChunks = document.extend({
chunk_start_index: z.number().optional().default(0),
});
-/**
- * Surfsense documentation schemas
- * Follows the same pattern as document/documentWithChunks
- */
-export const surfsenseDocsChunk = z.object({
- id: z.number(),
- content: z.string(),
-});
-
-export const surfsenseDocsDocument = z.object({
- id: z.number(),
- title: z.string(),
- source: z.string(),
- public_url: z.string(),
- content: z.string(),
-});
-
-export const surfsenseDocsDocumentWithChunks = surfsenseDocsDocument.extend({
- chunks: z.array(surfsenseDocsChunk),
-});
-
/**
* Get documents
*/
@@ -284,32 +262,6 @@ export const getDocumentChunksResponse = z.object({
has_more: z.boolean(),
});
-/**
- * Get Surfsense docs by chunk
- */
-export const getSurfsenseDocsByChunkRequest = z.object({
- chunk_id: z.number(),
-});
-
-export const getSurfsenseDocsByChunkResponse = surfsenseDocsDocumentWithChunks;
-
-/**
- * List Surfsense docs
- */
-export const getSurfsenseDocsRequest = z.object({
- queryParams: paginationQueryParams.extend({
- title: z.string().optional(),
- }),
-});
-
-export const getSurfsenseDocsResponse = z.object({
- items: z.array(surfsenseDocsDocument),
- total: z.number(),
- page: z.number(),
- page_size: z.number(),
- has_more: z.boolean(),
-});
-
/**
* Update document
*/
@@ -358,13 +310,6 @@ export type DeleteDocumentResponse = z.infer;
export type DocumentTypeEnum = z.infer;
export type DocumentSortBy = z.infer;
export type SortOrder = z.infer;
-export type SurfsenseDocsChunk = z.infer;
-export type SurfsenseDocsDocument = z.infer;
-export type SurfsenseDocsDocumentWithChunks = z.infer;
-export type GetSurfsenseDocsByChunkRequest = z.infer;
-export type GetSurfsenseDocsByChunkResponse = z.infer;
-export type GetSurfsenseDocsRequest = z.infer;
-export type GetSurfsenseDocsResponse = z.infer;
export type GetDocumentChunksRequest = z.infer;
export type GetDocumentChunksResponse = z.infer;
export type ChunkRead = z.infer;
diff --git a/surfsense_web/lib/apis/documents-api.service.ts b/surfsense_web/lib/apis/documents-api.service.ts
index 630c88d16..f9785c8a8 100644
--- a/surfsense_web/lib/apis/documents-api.service.ts
+++ b/surfsense_web/lib/apis/documents-api.service.ts
@@ -12,7 +12,6 @@ import {
type GetDocumentsRequest,
type GetDocumentsStatusRequest,
type GetDocumentTypeCountsRequest,
- type GetSurfsenseDocsRequest,
getDocumentByChunkRequest,
getDocumentByChunkResponse,
getDocumentChunksRequest,
@@ -25,9 +24,6 @@ import {
getDocumentsStatusResponse,
getDocumentTypeCountsRequest,
getDocumentTypeCountsResponse,
- getSurfsenseDocsByChunkResponse,
- getSurfsenseDocsRequest,
- getSurfsenseDocsResponse,
type SearchDocumentsRequest,
type SearchDocumentTitlesRequest,
searchDocumentsRequest,
@@ -363,48 +359,6 @@ class DocumentsApiService {
);
};
- /**
- * Get Surfsense documentation by chunk ID
- * Used for resolving [citation:doc-XXX] citations
- */
- getSurfsenseDocByChunk = async (chunkId: number) => {
- return baseApiService.get(
- `/api/v1/surfsense-docs/by-chunk/${chunkId}`,
- getSurfsenseDocsByChunkResponse
- );
- };
-
- /**
- * List all Surfsense documentation documents
- * @param request - The request with query params
- * @param signal - Optional AbortSignal for request cancellation
- */
- getSurfsenseDocs = async (request: GetSurfsenseDocsRequest, signal?: AbortSignal) => {
- const parsedRequest = getSurfsenseDocsRequest.safeParse(request);
-
- if (!parsedRequest.success) {
- console.error("Invalid request:", parsedRequest.error);
-
- const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
- throw new ValidationError(`Invalid request: ${errorMessage}`);
- }
-
- // Transform query params to be string values
- const transformedQueryParams = parsedRequest.data.queryParams
- ? Object.fromEntries(
- Object.entries(parsedRequest.data.queryParams).map(([k, v]) => [k, String(v)])
- )
- : undefined;
-
- const queryParams = transformedQueryParams
- ? new URLSearchParams(transformedQueryParams).toString()
- : "";
-
- const url = `/api/v1/surfsense-docs?${queryParams}`;
-
- return baseApiService.get(url, getSurfsenseDocsResponse, { signal });
- };
-
/**
* Update a document
*/
diff --git a/surfsense_web/lib/chat/thread-persistence.ts b/surfsense_web/lib/chat/thread-persistence.ts
index abe6bc02c..d30b87665 100644
--- a/surfsense_web/lib/chat/thread-persistence.ts
+++ b/surfsense_web/lib/chat/thread-persistence.ts
@@ -221,7 +221,6 @@ export interface RegenerateParams {
content: string;
}>;
mentionedDocumentIds?: number[];
- mentionedSurfsenseDocIds?: number[];
}
/**
diff --git a/surfsense_web/lib/documents/document-type-labels.ts b/surfsense_web/lib/documents/document-type-labels.ts
index 844961886..9e187f940 100644
--- a/surfsense_web/lib/documents/document-type-labels.ts
+++ b/surfsense_web/lib/documents/document-type-labels.ts
@@ -25,7 +25,6 @@ export function getDocumentTypeLabel(type: string): string {
CIRCLEBACK: "Circleback",
OBSIDIAN_CONNECTOR: "Obsidian",
LOCAL_FOLDER_FILE: "Local Folder",
- SURFSENSE_DOCS: "SurfSense Docs",
NOTE: "Note",
COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "Composio Google Drive",
COMPOSIO_GMAIL_CONNECTOR: "Composio Gmail",
diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts
index 8943d6842..35724cf94 100644
--- a/surfsense_web/lib/query-client/cache-keys.ts
+++ b/surfsense_web/lib/query-client/cache-keys.ts
@@ -30,7 +30,6 @@ export const cacheKeys = {
withQueryParams: (queries: GetDocumentsRequest["queryParams"]) =>
["documents-with-queries", ...stableEntries(queries)] as const,
document: (documentId: string) => ["document", documentId] as const,
- byChunk: (chunkId: string) => ["documents", "by-chunk", chunkId] as const,
},
logs: {
list: (searchSpaceId?: number | string) => ["logs", "list", searchSpaceId] as const,
diff --git a/surfsense_web/tsc_out.txt b/surfsense_web/tsc_out.txt
new file mode 100644
index 0000000000000000000000000000000000000000..c51e470851df2ea0ac136579d4d6112e183bad16
GIT binary patch
literal 32582
zcmeI5Yi|_E6^8qBr2L1GKg2{@Fg6(2EFm&p!;%0-HlSUtBFh-t%i`O523TjqUr+Kr
zb>>jj-PJu8U+|KaHJ+L7>Z(&!=klIYr)vKDpUvV(e{;o^;-})U*e|w=KkM78Vo%?8
z_36i=ug~xF`JeiBRBY&d?qJ>5Ol-=SW;;(4)NEEKm@
z=Ie0psjm0*dLK&B#%A$aSN8QjD7Hkyw(9Na^H0T_@M%kT_VjnH_+#-0-RTvpwWsHG
z^|08{{{>OfD`rL4QJ~>SbwJZ^dTpu9zOEk@2WsUY^x$vd?wY=H_gJ*t)e{4KdZQA%
zp|<7PbI*$t)w8X#zZD;gd;04Y|JLiBo_Ve7JK^bVef~yQKZaVUpEkEOYJ_VvuMTJD8wT+NTrY@lOT
zb-@!h^Stkbe!(FhhI5f}R^$da!smmTe^Pua`s;Jn*S(%$7SYgllM9gb)1XF`v2hEy!}9vqmi%`Zb@>sV_|4J?sT?8
zqt)4t&UUyh8;vbG+tHyOQ0VU$^?>=ofM(9b8Vn^u=Lf?5?5Y_tDCw~_7JRRcvuU?9rX=M+1$59-M79`
zyLne2TfRAlOcCu9^#xkZn_C~!M*l#*Rs8GT6Fo9t!u*$VE#@V7i67-JHer7Aif9YJ
z??mr-IpA@wfYBM5x8WLh@BwW`n5FG7mUxQzMX^_XmDvft7o%--?uFW;zX@8w1Hh-i
z$Bh049)Z<~ukuLs*gf+YK8OOI!z=V2+UUX=cv3cdHuCruRvSLcufZ3!@%9<9F^10$
z)KB-w7!mmKIE+B_aoi@rnfC)F+wysS3jP%wRGx=k@h8<&zQ)|_>koc-rsoca<3LGf
z7f+3vxNl=d8T=u*Y9_P^KSj=-3BGOpxyZ$g6i?U7q_i)o&T`FV%Acw2W7Pyd!8i5V
zjHW^05ZWt0@4OBF_AwTd{nGX5{^++W=AA5ubGf%nPinW9Ur7}62nXhSQKaO*~R-+dL
z&4{9VUBA0#iJm@j8E7;uXB6*?d*Fc>1F#5>+16B7F6+6d1K`T(Z2~o+&xq9!Ex9V5
zK{DUzh0RFsUzKK{j9E+O1aIM2aj8e*ZindCjaD0Yvser=xulwQ)egK*Z=5rtHl5AH
zP+Sk)4fi7L4~CRQ&2leTlJTQ0E-NOBrrC{Gs}$8+Wk$-j*k>f=erB>>a#=dgtoGhE_H$Qjf{f^ZIN?9x}AErdBPQ`Bdeh!BB;jGqgY?wq7od(pHT_K$t#b
z(WXVTmQprKGVMNX90cpVt5KQ@TC*;pAi2*Wq0ngK4Zhe-FutAMzz-$CCSYcU0#Vc+izNd*sXhGsdet_s+Eg2_v;Y9#QSXa
z%_gzAAVTzI8H=NP4TtYaa#)uGnP1ga)~WD0a`ZMKbS%#x!S)Z6I(u3@@j9EHH#@Ku
z${l^OV@BV4p#50(4d3cSHKYI2TFt|O{=WPQ&~?4Prc2YYcl$;wE7G$d7t?2Hsizt@
z5X80Op;if3_5ZO-Z>Ude^5|ZM&_0RDUaa{<%J{HPC>^i4R9>uLCYK-_QDq^*GWv
z&S~`LgDtVrbDEntLvzUtRq-5BLM^ZLH`h24L?QNMRlZZL*eS5(g$J9~;o!5>znKirjAsLAqm6#^bzX(>f*-(EZ4m?GySM5y7}-Z*(po;2
zu#@u@xCwgeyclI~Q}d0#wtCCS^Gq6gvFxNtEK0|Vq2#fh5Yt;Eh*YE@XrEezh
zR}_c7r5T=Yj7QHamxw&5+ky9H^<-?3IkXj9R-tXZob_jJr(c`Ey5fZR-!e
z8XtBbELy+DI(Ky+2{c^~+}RvSUl?XNr9Rdb@p7;DYuJPFUUVbBu_vyPe64TP>TFt@
zX`Hyi=(U&EZc|?v
zM)sG)A8rBq!$`iZOt$maH2owuu9>GsTRzLky4?LF`|&jMNqlKba*KRVZqYAlHtFS}
zwA-|^`FVQj8KA^l$#=x^M`^q%r=y_&MJ=n?Cc~@&`&y^$iIMnuDkTUX9W&IeJrC%SmrvSq7iAMC~&eeP+?RW!|;
zyAOJVKN*MIeaCOPd#r2_k7|0BEJMpa&)1C>@Mu=Pe%BTGyD3JM_7qr333$i~XmVmZBD7dkdy7ulGgV06@mUWRI^5e~8EY14Crr^kNO
zXMAKUi{3`Zg&h}8&V}q^crJ@h{wY2lJ0q}0p6~0{7>iln4dE8ucf-VJ4z|&(GZ7tf
zJ@E~h9WoHeYvmJo1DylP@jd&lZE3AJ2Yn_;uFv**@DkCD>w2Z~JQGl=w~LgU+~8Fr
z^|}0Xr@%8kC>OJ!syf?s6LFkF$-T*7PLE(4-m3&VUC~;sChui6PQK)Z#tE#m)%uKi
z%V4Gr*%`}&Wo5XHqNZoYYti?A(D#@-@5r*fj&a*;@aA5z-aZdDk$7St&J&IG>_mB2?#)@T9S%2b19I$exZ`Nf@9A)O=M7f1>hIYb>~MJW0;>C5
zI)pSD`Hw<5=Z}y&93E}8Bet>!(l!U>k`dLlJ=5g5P4monEq;D3>u|X3E@qd;+2L^3
z1UnqQ8}>(a@o>{)O%V}%5s$reINaC6b2_(Mm8s{nIp~MI8!=onm^|YL3>$0Q1yzHni35TCW
z9!jbW)79L(Jj9H?XwAzjy1C~-2xM+-gxnm?J!sBQv2wO+fR!lng2^!^;~`gfeqDJ~
zOHEIW-)?!_EGsEyD_2*LVrkPnxt=%zV#i!u1^1Xp)pBhppUO{a-56#?HMf6J`6jWS
z%y!HT$o02eHa{b|=Q?hRBR38=<$ew2^xVYE%t$iH(Ivasvp?dMbpCU9EwjUSw^U15
z&t^gfUp)uJtD
zRqWy?Tio_T=FVv19JjtQ7m4s0o&1Usdt{q`h2loYOkm8bd4x^-MO{jA5@h3<-%)Gx
zx~_c*Yw8>SH-^2OQR|%VH4}FBzf`{;>*SdFU7lUibKmQfvxk}ckM-nJoeS_n_1B-i
zuV)`=Z^R4T^O}~mXQEzmUF|oYFpyFUJx^3y?j(X(j&s>o)NQlXt7WM4c^Wal*
z!DEKFoi7PaT@Lmj)u}&9Q?_Z^nc<1@^m9W!WnUh-?mh-cZ++LBm1gG(=*su?V2}J9
zM%!;klsVQ(Hhf+un6C2O)i$fXb7nVakTp?7Kk~m$SN;Wodts&1wPTvi*@R=8=zHdfO_eVoeY05vpy4SQD+hBXRx`>^EtnGi+FnN)P~>EQxxjB?UryEan+{%
zvz#e#U0Kb@^0S~f%o7HuaxU$x5UigUz-frK&&x8Z+1ui0LXsg(Y2HtQ9`yY)rc-ms
zvz>AfC%GBT`*<4
zorph--x=dq;cSl>9g5P)DHQO^p<494?);7sdvo!mn)kX-!Y2<@zelDXh#oxOl`u>E
zN|fuA=_zeLH{!BTirpP^Piu6
zKkesE&}~>R&Mc35D|gNhcoH0ph5=1u9m_IrPod+`i*qUi3O2jr8ke2+$aKos
zuFEY>Ll3`EZQAGZP;}!0DC%pDIDsE~bxr@HXW=u&dOv5!G>^-oW()kZDvkfM>cR8r
z1uJXzko~UM2m2*ATCg*t`rR{jLKz51UuGE25e=W}*
zJ7?CHbHD7I5I^TB?p4ltCl6KYp?U$u%gIyd
zOinFS!4JHj{jOSk&$K6Y)=PS&-Z%S|F6IG@z%F0L3x2clVx4&qzW=P*XVeTofgO;X
LlrupwRW$TJ&LaAJ
literal 0
HcmV?d00001