chore: linting

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-05-28 19:21:29 -07:00
parent 4dda02c06c
commit 94e834134f
80 changed files with 443 additions and 404 deletions

View file

@ -98,9 +98,7 @@ def upgrade() -> None:
op.execute( op.execute(
"CREATE INDEX ix_automation_triggers_automation_id ON automation_triggers(automation_id);" "CREATE INDEX ix_automation_triggers_automation_id ON automation_triggers(automation_id);"
) )
op.execute( op.execute("CREATE INDEX ix_automation_triggers_type ON automation_triggers(type);")
"CREATE INDEX ix_automation_triggers_type ON automation_triggers(type);"
)
op.execute( op.execute(
"CREATE INDEX ix_automation_triggers_enabled ON automation_triggers(enabled);" "CREATE INDEX ix_automation_triggers_enabled ON automation_triggers(enabled);"
) )

View file

@ -28,7 +28,6 @@ from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
_HEADER = """\ _HEADER = """\
You are the SurfSense automation drafter. Convert the user intent below You are the SurfSense automation drafter. Convert the user intent below
into a SINGLE JSON object matching the AutomationCreate schema. Output into a SINGLE JSON object matching the AutomationCreate schema. Output

View file

@ -404,9 +404,7 @@ def build_task_tool_with_parent_config(
continue continue
messages = payload.get("messages") or [] messages = payload.get("messages") or []
last_text = _safe_message_text(messages[-1]).rstrip() if messages else "" last_text = _safe_message_text(messages[-1]).rstrip() if messages else ""
message_blocks.append( message_blocks.append(f"[task {task_index}] {last_text or '<empty>'}")
f"[task {task_index}] {last_text or '<empty>'}"
)
try: try:
child_trace = _build_tool_trace(messages) child_trace = _build_tool_trace(messages)
except Exception: except Exception:

View file

@ -117,9 +117,7 @@ def create_generate_podcast_tool(
"podcast_id": podcast_id, "podcast_id": podcast_id,
"title": podcast_title, "title": podcast_title,
"file_location": file_location, "file_location": file_location,
"message": ( "message": ("Podcast generated and saved to your podcast panel."),
"Podcast generated and saved to your podcast panel."
),
} }
return with_receipt( return with_receipt(
payload=payload, payload=payload,

View file

@ -126,8 +126,7 @@ def create_generate_video_presentation_tool(
elapsed, elapsed,
) )
err = ( err = (
"Background worker reported FAILED status for this " "Background worker reported FAILED status for this video presentation."
"video presentation."
) )
payload = { payload = {
"status": VideoPresentationStatus.FAILED.value, "status": VideoPresentationStatus.FAILED.value,
@ -151,9 +150,7 @@ def create_generate_video_presentation_tool(
except Exception as e: except Exception as e:
error_message = str(e) error_message = str(e)
logger.exception( logger.exception("[generate_video_presentation] Error: %s", error_message)
"[generate_video_presentation] Error: %s", error_message
)
payload = { payload = {
"status": VideoPresentationStatus.FAILED.value, "status": VideoPresentationStatus.FAILED.value,
"error": error_message, "error": error_message,

View file

@ -131,9 +131,7 @@ def create_generate_podcast_tool(
"podcast_id": podcast_id, "podcast_id": podcast_id,
"title": podcast_title, "title": podcast_title,
"file_location": file_location, "file_location": file_location,
"message": ( "message": ("Podcast generated and saved to your podcast panel."),
"Podcast generated and saved to your podcast panel."
),
} }
# Only other terminal state is FAILED. # Only other terminal state is FAILED.
@ -146,9 +144,7 @@ def create_generate_podcast_tool(
"status": PodcastStatus.FAILED.value, "status": PodcastStatus.FAILED.value,
"podcast_id": podcast_id, "podcast_id": podcast_id,
"title": podcast_title, "title": podcast_title,
"error": ( "error": ("Background worker reported FAILED status for this podcast."),
"Background worker reported FAILED status for this podcast."
),
} }
except Exception as e: except Exception as e:

View file

@ -127,9 +127,7 @@ def create_generate_video_presentation_tool(
except Exception as e: except Exception as e:
error_message = str(e) error_message = str(e)
logger.exception( logger.exception("[generate_video_presentation] Error: %s", error_message)
"[generate_video_presentation] Error: %s", error_message
)
return { return {
"status": VideoPresentationStatus.FAILED.value, "status": VideoPresentationStatus.FAILED.value,
"error": error_message, "error": error_message,

View file

@ -21,4 +21,4 @@ __all__ = [
] ]
# Built-in actions self-register at import time. # Built-in actions self-register at import time.
from . import agent_task # noqa: E402, F401 from . import agent_task # noqa: F401

View file

@ -12,4 +12,4 @@ from .params import AgentTaskActionParams
__all__ = ["AgentTaskActionParams", "build_handler"] __all__ = ["AgentTaskActionParams", "build_handler"]
# Side-effect: register on the actions store. # Side-effect: register on the actions store.
from . import definition # noqa: E402, F401 from . import definition # noqa: F401

View file

@ -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 app.db import ChatVisibility, async_session_maker
from ..types import ActionContext from ..types import ActionContext
from .auto_decide import build_auto_decisions from .auto_decide import build_auto_decisions
from .dependencies import build_dependencies from .dependencies import build_dependencies
from .finalize import extract_final_assistant_message from .finalize import extract_final_assistant_message

View file

@ -50,7 +50,7 @@ class AutomationRun(BaseModel, TimestampMixin):
definition_snapshot = Column(JSONB, nullable=False) definition_snapshot = Column(JSONB, nullable=False)
# merged & validated inputs the run was dispatched with # 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="{}") inputs = Column(JSONB, nullable=False, server_default="{}")
# one entry per executed step; agent_task entries carry their own # one entry per executed step; agent_task entries carry their own
# `agent_session_id` inside their entry # `agent_session_id` inside their entry

View file

@ -6,9 +6,9 @@ from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession 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.enums.run_status import RunStatus
from app.automations.persistence.models.run import AutomationRun 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.envelope import AutomationDefinition
from app.automations.schemas.definition.plan_step import PlanStep from app.automations.schemas.definition.plan_step import PlanStep
from app.automations.templating import build_run_context 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( await repository.mark_failed(
session, session,
run, run,
{"message": f"definition_snapshot invalid: {exc}", "type": type(exc).__name__}, {
"message": f"definition_snapshot invalid: {exc}",
"type": type(exc).__name__,
},
) )
await session.commit() await session.commit()
return return
@ -92,7 +95,9 @@ async def _run_on_failure(
await session.commit() 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 automation = run.automation
trigger = run.trigger trigger = run.trigger
return build_run_context( return build_run_context(

View file

@ -30,14 +30,18 @@ async def execute_step(
try: try:
should_run = evaluate_predicate(step.when, template_context) should_run = evaluate_predicate(step.when, template_context)
except Exception as exc: 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: if not should_run:
return _result(step, "skipped", started_at, attempts=0) return _result(step, "skipped", started_at, attempts=0)
try: try:
resolved_params = render_value(step.params, template_context) resolved_params = render_value(step.params, template_context)
except Exception as exc: 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) action = get_action(step.action)
if action is None: if action is None:
@ -46,12 +50,17 @@ async def execute_step(
"failed", "failed",
started_at, started_at,
attempts=0, 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) 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 timeout = step.timeout_seconds or default_timeout_seconds
try: try:
@ -62,7 +71,9 @@ async def execute_step(
timeout=timeout, timeout=timeout,
) )
except Exception as exc: 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) return _result(step, "succeeded", started_at, attempts=attempts, result=result)

View file

@ -12,7 +12,9 @@ from .plan_step import PlanStep
class Execution(BaseModel): class Execution(BaseModel):
model_config = ConfigDict(extra="forbid") 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.") max_retries: int = Field(default=2, ge=0, description="Per-step retry budget.")
retry_backoff: Literal["exponential", "linear", "none"] = "exponential" retry_backoff: Literal["exponential", "linear", "none"] = "exponential"
concurrency: Literal["drop_if_running", "queue", "always"] = "drop_if_running" concurrency: Literal["drop_if_running", "queue", "always"] = "drop_if_running"

View file

@ -11,7 +11,9 @@ class PlanStep(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
step_id: str = Field(..., min_length=1, description="Unique within the plan.") 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( when: str | None = Field(
default=None, default=None,
description="Optional predicate; step is skipped when falsy.", description="Optional predicate; step is skipped when falsy.",

View file

@ -10,7 +10,9 @@ from pydantic import BaseModel, ConfigDict, Field
class TriggerSpec(BaseModel): class TriggerSpec(BaseModel):
model_config = ConfigDict(extra="forbid") 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( params: dict[str, Any] = Field(
default_factory=dict, default_factory=dict,
description="Type-specific params; validated against the trigger's schema.", description="Type-specific params; validated against the trigger's schema.",

View file

@ -10,14 +10,14 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload 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 ( from app.automations.schemas.api import (
AutomationCreate, AutomationCreate,
AutomationUpdate, AutomationUpdate,
TriggerCreate, 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 import get_trigger
from app.automations.triggers.schedule import compute_next_fire_at from app.automations.triggers.schedule import compute_next_fire_at
from app.db import Permission, User, get_async_session from app.db import Permission, User, get_async_session
@ -34,7 +34,9 @@ class AutomationService:
async def create(self, payload: AutomationCreate) -> Automation: async def create(self, payload: AutomationCreate) -> Automation:
"""Create an automation and its initial triggers in one transaction.""" """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( automation = Automation(
search_space_id=payload.search_space_id, search_space_id=payload.search_space_id,
@ -67,22 +69,32 @@ class AutomationService:
) )
rows = ( 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) return list(rows), int(total or 0)
async def get(self, automation_id: int) -> Automation: async def get(self, automation_id: int) -> Automation:
"""Get an automation with its triggers loaded.""" """Get an automation with its triggers loaded."""
automation = await self._get_with_triggers_or_raise(automation_id) 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 return automation
async def update(self, automation_id: int, patch: AutomationUpdate) -> Automation: async def update(self, automation_id: int, patch: AutomationUpdate) -> Automation:
"""Patch fields. Bumps ``version`` when ``definition`` changes.""" """Patch fields. Bumps ``version`` when ``definition`` changes."""
automation = await self._get_with_triggers_or_raise(automation_id) 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) data = patch.model_dump(exclude_unset=True)
@ -93,7 +105,9 @@ class AutomationService:
if "status" in data: if "status" in data:
automation.status = data["status"] automation.status = data["status"]
if "definition" in data: 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 automation.version += 1
await self.session.commit() await self.session.commit()
@ -102,7 +116,9 @@ class AutomationService:
async def delete(self, automation_id: int) -> None: async def delete(self, automation_id: int) -> None:
"""Delete an automation; FK cascades remove triggers and runs.""" """Delete an automation; FK cascades remove triggers and runs."""
automation = await self._get_or_raise(automation_id) 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.delete(automation)
await self.session.commit() 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.""" """Validate trigger params via its registered Pydantic model and build the ORM row."""
definition = get_trigger(spec.type.value) definition = get_trigger(spec.type.value)
if definition is None: 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: try:
validated = definition.params_model.model_validate(spec.params) validated = definition.params_model.model_validate(spec.params)

View file

@ -36,10 +36,16 @@ class RunService:
) )
rows = ( 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) return list(rows), int(total or 0)
async def get(self, *, automation_id: int, run_id: int) -> AutomationRun: async def get(self, *, automation_id: int, run_id: int) -> AutomationRun:

View file

@ -8,10 +8,10 @@ from fastapi import Depends, HTTPException
from pydantic import ValidationError from pydantic import ValidationError
from sqlalchemy.ext.asyncio import AsyncSession 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.enums.trigger_type import TriggerType
from app.automations.persistence.models.automation import Automation from app.automations.persistence.models.automation import Automation
from app.automations.persistence.models.trigger import AutomationTrigger 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 import get_trigger
from app.automations.triggers.schedule import compute_next_fire_at from app.automations.triggers.schedule import compute_next_fire_at
from app.db import Permission, User, get_async_session from app.db import Permission, User, get_async_session
@ -40,7 +40,9 @@ class TriggerService:
params=validated_params, params=validated_params,
static_inputs=payload.static_inputs, static_inputs=payload.static_inputs,
enabled=payload.enabled, 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) self.session.add(trigger)
await self.session.commit() await self.session.commit()
@ -54,7 +56,9 @@ class TriggerService:
trigger_id: int, trigger_id: int,
patch: TriggerUpdate, patch: TriggerUpdate,
) -> AutomationTrigger: ) -> 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) trigger = await self._get_trigger_or_raise(automation_id, trigger_id)
data = patch.model_dump(exclude_unset=True) data = patch.model_dump(exclude_unset=True)
@ -80,7 +84,9 @@ class TriggerService:
return trigger return trigger
async def remove(self, *, automation_id: int, trigger_id: int) -> None: 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) trigger = await self._get_trigger_or_raise(automation_id, trigger_id)
await self.session.delete(trigger) await self.session.delete(trigger)
await self.session.commit() await self.session.commit()

View file

@ -17,7 +17,7 @@ TASK_NAME = "automation_run_execute"
@celery_app.task(name=TASK_NAME, bind=True) @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.""" """Execute one ``AutomationRun``. Idempotent: terminal runs no-op."""
return run_async_celery_task(lambda: _impl(run_id)) return run_async_celery_task(lambda: _impl(run_id))

View file

@ -103,9 +103,7 @@ async def _self_heal_null_next_fire(session: AsyncSession, *, now: datetime) ->
await session.commit() await session.commit()
async def _claim_due_triggers( async def _claim_due_triggers(session: AsyncSession, *, now: datetime) -> list[_Claim]:
session: AsyncSession, *, now: datetime
) -> list[_Claim]:
"""Lock and advance due rows; return per-trigger fire context.""" """Lock and advance due rows; return per-trigger fire context."""
stmt = ( stmt = (
select(AutomationTrigger) select(AutomationTrigger)

View file

@ -17,4 +17,4 @@ __all__ = [
] ]
# Built-in triggers self-register at import time. # Built-in triggers self-register at import time.
from . import schedule # noqa: E402, F401 from . import schedule # noqa: F401

View file

@ -15,4 +15,4 @@ __all__ = [
] ]
# Side-effect: register on the triggers store. # Side-effect: register on the triggers store.
from . import definition # noqa: E402, F401 from . import definition # noqa: F401

View file

@ -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. given timezone before evaluation so DST and IANA rules apply correctly.
""" """
tz = ZoneInfo(timezone) 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) nxt: datetime = croniter(cron, base).get_next(datetime)
return nxt.astimezone(UTC) return nxt.astimezone(UTC)

View file

@ -10,7 +10,9 @@ from .cron import InvalidCronError, validate_cron
class ScheduleTriggerParams(BaseModel): class ScheduleTriggerParams(BaseModel):
model_config = ConfigDict(extra="forbid") 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"]) timezone: str = Field(..., description="IANA timezone.", examples=["Africa/Kigali"])
@model_validator(mode="after") @model_validator(mode="after")

View file

@ -2605,7 +2605,6 @@ from app.automations.persistence import ( # noqa: E402, F401
AutomationTrigger, AutomationTrigger,
) )
engine = create_async_engine( engine = create_async_engine(
DATABASE_URL, DATABASE_URL,
pool_size=30, pool_size=30,

View file

@ -1,5 +1,7 @@
from fastapi import APIRouter 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_action_log_route import router as agent_action_log_router
from .agent_flags_route import router as agent_flags_router from .agent_flags_route import router as agent_flags_router
from .agent_permissions_route import router as agent_permissions_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 ( from .airtable_add_connector_route import (
router as airtable_add_connector_router, 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 .chat_comments_routes import router as chat_comments_router
from .circleback_webhook_route import router as circleback_webhook_router from .circleback_webhook_route import router as circleback_webhook_router
from .clickup_add_connector_route import router as clickup_add_connector_router from .clickup_add_connector_route import router as clickup_add_connector_router

View file

@ -19,9 +19,7 @@ def extract_todos_from_deepagents(command_output: Any) -> dict:
elif isinstance(command_output, dict): elif isinstance(command_output, dict):
if "todos" in command_output: if "todos" in command_output:
todos_data = command_output.get("todos", []) todos_data = command_output.get("todos", [])
elif "update" in command_output and isinstance( elif "update" in command_output and isinstance(command_output["update"], dict):
command_output["update"], dict
):
todos_data = command_output["update"].get("todos", []) todos_data = command_output["update"].get("todos", [])
return {"todos": todos_data} return {"todos": todos_data}

View file

@ -69,17 +69,13 @@ async def resolve_initial_auto_pin(
"pin.requires_image_input": requires_image_input, "pin.requires_image_input": requires_image_input,
}, },
) )
return AutoPinResult( return AutoPinResult(llm_config_id=pinned.resolved_llm_config_id, error=None)
llm_config_id=pinned.resolved_llm_config_id, error=None
)
except ValueError as pin_error: except ValueError as pin_error:
# The "no vision-capable cfg" path raises a ValueError whose message # 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 # 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 # same message regardless of whether the gate fired in the resolver or
# in ``llm_capability.assert_vision_capability_for_image_turn``. # in ``llm_capability.assert_vision_capability_for_image_turn``.
is_vision_failure = ( is_vision_failure = requires_image_input and "vision-capable" in str(pin_error)
requires_image_input and "vision-capable" in str(pin_error)
)
error_code = ( error_code = (
"MODEL_DOES_NOT_SUPPORT_IMAGE_INPUT" "MODEL_DOES_NOT_SUPPORT_IMAGE_INPUT"
if is_vision_failure if is_vision_failure

View file

@ -207,9 +207,7 @@ async def _resolve_mentions_for_query(
try: try:
chip_objs.append(MentionedDocumentInfo.model_validate(raw)) chip_objs.append(MentionedDocumentInfo.model_validate(raw))
except Exception: except Exception:
logger.debug( logger.debug("stream_new_chat: dropping malformed mention chip %r", raw)
"stream_new_chat: dropping malformed mention chip %r", raw
)
resolved = await resolve_mentions( resolved = await resolve_mentions(
session, session,

View file

@ -48,9 +48,7 @@ def check_image_input_capability(
return None return None
model_label = agent_config.config_name or agent_config.model_name or "model" model_label = agent_config.config_name or agent_config.model_name or "model"
ot.add_event( ot.add_event("quota.denied", {"quota.code": "MODEL_DOES_NOT_SUPPORT_IMAGE_INPUT"})
"quota.denied", {"quota.code": "MODEL_DOES_NOT_SUPPORT_IMAGE_INPUT"}
)
return ( return (
( (
f"The selected model ({model_label}) does not support " f"The selected model ({model_label}) does not support "

View file

@ -259,7 +259,8 @@ async def stream_new_chat(
if needs_premium_quota(agent_config, user_id): if needs_premium_quota(agent_config, user_id):
premium_reservation = await reserve_premium( 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: 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"})
@ -492,7 +493,9 @@ async def stream_new_chat(
# --- Block 4: First SSE frames --- # --- 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 yield sse
# --- Block 5: Persistence join + message-id frames --- # --- 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_search_space_id=search_space_id,
fallback_commit_created_by_id=user_id, fallback_commit_created_by_id=user_id,
fallback_commit_filesystem_mode=( 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, fallback_commit_thread_id=chat_id,
runtime_context=runtime_context, runtime_context=runtime_context,
@ -715,11 +720,7 @@ async def stream_new_chat(
title_emitted = True title_emitted = True
# Account for the case where the task completed but produced no # Account for the case where the task completed but produced no
# title — flip the flag anyway so we don't keep checking it. # title — flip the flag anyway so we don't keep checking it.
if ( if title_task is not None and title_task.done() and not title_emitted:
title_task is not None
and title_task.done()
and not title_emitted
):
title_emitted = True title_emitted = True
_perf_log.info( _perf_log.info(
@ -811,9 +812,7 @@ async def stream_new_chat(
end_turn(str(chat_id)) end_turn(str(chat_id))
if premium_reservation is not None and user_id: if premium_reservation is not None and user_id:
await release_premium( await release_premium(reservation=premium_reservation, user_id=user_id)
reservation=premium_reservation, user_id=user_id
)
await close_session_and_clear_ai_responding(session, chat_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 # Break circular refs held by the agent graph, tools, and LLM
# wrappers so the GC can reclaim them in a single pass. # wrappers so the GC can reclaim them in a single pass.
agent = llm = connector_service = None # noqa: F841 agent = llm = connector_service = None
input_state = stream_result = None # noqa: F841 input_state = stream_result = None
session = None # noqa: F841 session = None
run_gc_pass(log_prefix="stream_new_chat", chat_id=chat_id) run_gc_pass(log_prefix="stream_new_chat", chat_id=chat_id)
close_chat_request_span( close_chat_request_span(

View file

@ -30,9 +30,7 @@ def build_new_chat_runtime_context(
return SurfSenseContextSchema( return SurfSenseContextSchema(
search_space_id=search_space_id, search_space_id=search_space_id,
mentioned_document_ids=list(mentioned_document_ids or []), mentioned_document_ids=list(mentioned_document_ids or []),
mentioned_folder_ids=list( mentioned_folder_ids=list(accepted_folder_ids or mentioned_folder_ids or []),
accepted_folder_ids or mentioned_folder_ids or []
),
request_id=request_id, request_id=request_id,
turn_id=turn_id, turn_id=turn_id,
) )

View file

@ -133,12 +133,8 @@ async def _generate_title(
# inherited Azure endpoint — see ``provider_api_base`` for the # inherited Azure endpoint — see ``provider_api_base`` for the
# same bug repro on the image-gen / vision paths. # same bug repro on the image-gen / vision paths.
raw_model = getattr(llm, "model", "") or "" raw_model = getattr(llm, "model", "") or ""
provider_prefix = ( provider_prefix = raw_model.split("/", 1)[0] if "/" in raw_model else None
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_value = (
agent_config.provider if agent_config is not None else None
)
title_api_base = resolve_api_base( title_api_base = resolve_api_base(
provider=provider_value, provider=provider_value,
provider_prefix=provider_prefix, provider_prefix=provider_prefix,

View file

@ -15,14 +15,10 @@ building blocks under ``flows/shared/``. Mirrors ``stream_new_chat`` but:
from __future__ import annotations from __future__ import annotations
import contextlib import contextlib
import gc
import logging import logging
import sys
import time import time
import uuid as _uuid
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from functools import partial from functools import partial
from typing import Any
from uuid import UUID from uuid import UUID
import anyio 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.filesystem_selection import FilesystemMode, FilesystemSelection
from app.agents.new_chat.middleware.busy_mutex import end_turn from app.agents.new_chat.middleware.busy_mutex import end_turn
from app.config import config as _app_config 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.observability import otel as ot
from app.services.chat_session_state_service import set_ai_responding from app.services.chat_session_state_service import set_ai_responding
from app.services.new_streaming_service import VercelStreamingService 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.stream_result import StreamResult
from app.tasks.chat.streaming.shared.utils import resume_step_prefix 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__) logger = logging.getLogger(__name__)
_perf_log = get_perf_logger() _perf_log = get_perf_logger()
@ -217,12 +213,11 @@ async def stream_resume_chat(
if needs_premium_quota(agent_config, user_id): if needs_premium_quota(agent_config, user_id):
premium_reservation = await reserve_premium( 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: if not premium_reservation.allowed:
ot.add_event( ot.add_event("quota.denied", {"quota.code": "PREMIUM_QUOTA_EXHAUSTED"})
"quota.denied", {"quota.code": "PREMIUM_QUOTA_EXHAUSTED"}
)
if requested_llm_config_id == 0: if requested_llm_config_id == 0:
try: try:
pinned_fb = await resolve_or_get_pinned_llm_config_id( pinned_fb = await resolve_or_get_pinned_llm_config_id(
@ -396,7 +391,9 @@ async def stream_resume_chat(
# --- First SSE frames --- # --- 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 yield sse
# --- Assistant-shell persistence + id frame --- # --- Assistant-shell persistence + id frame ---
@ -517,7 +514,9 @@ async def stream_resume_chat(
fallback_commit_search_space_id=search_space_id, fallback_commit_search_space_id=search_space_id,
fallback_commit_created_by_id=user_id, fallback_commit_created_by_id=user_id,
fallback_commit_filesystem_mode=( 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, fallback_commit_thread_id=chat_id,
runtime_context=runtime_context, runtime_context=runtime_context,
@ -589,9 +588,7 @@ async def stream_resume_chat(
end_turn(str(chat_id)) end_turn(str(chat_id))
if premium_reservation is not None and user_id: if premium_reservation is not None and user_id:
await release_premium( await release_premium(reservation=premium_reservation, user_id=user_id)
reservation=premium_reservation, user_id=user_id
)
await close_session_and_clear_ai_responding(session, chat_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: if not busy_error_raised:
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
end_turn(str(chat_id)) end_turn(str(chat_id))
_perf_log.info( _perf_log.info("[stream_resume] end_turn cleanup (chat_id=%s)", chat_id)
"[stream_resume] end_turn cleanup (chat_id=%s)", chat_id
)
agent = llm = connector_service = None # noqa: F841 agent = llm = connector_service = None
stream_result = None # noqa: F841 stream_result = None
session = None # noqa: F841 session = None
run_gc_pass(log_prefix="stream_resume", chat_id=chat_id) run_gc_pass(log_prefix="stream_resume", chat_id=chat_id)
close_chat_request_span( close_chat_request_span(

View file

@ -47,9 +47,7 @@ async def build_resume_routing(
slice_decisions_by_tool_call, slice_decisions_by_tool_call,
) )
parent_state = await agent.aget_state( parent_state = await agent.aget_state({"configurable": {"thread_id": str(chat_id)}})
{"configurable": {"thread_id": str(chat_id)}}
)
pending = collect_pending_tool_calls(parent_state) pending = collect_pending_tool_calls(parent_state)
_perf_log.info( _perf_log.info(
"[hitl_route] resume_entry chat_id=%s decisions=%d pending_subagents=%d", "[hitl_route] resume_entry chat_id=%s decisions=%d pending_subagents=%d",

View file

@ -49,9 +49,7 @@ async def finalize_assistant_message(
was never assigned. was never assigned.
""" """
if not ( if not (
stream_result stream_result and stream_result.turn_id and stream_result.assistant_message_id
and stream_result.turn_id
and stream_result.assistant_message_id
): ):
return return

View file

@ -39,9 +39,7 @@ async def close_session_and_clear_ai_responding(
async with shielded_async_session() as fresh_session: async with shielded_async_session() as fresh_session:
await clear_ai_responding(fresh_session, chat_id) await clear_ai_responding(fresh_session, chat_id)
except Exception: except Exception:
logger.warning( logger.warning("Failed to clear AI responding state for thread %s", chat_id)
"Failed to clear AI responding state for thread %s", chat_id
)
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
session.expunge_all() session.expunge_all()

View file

@ -41,9 +41,7 @@ class PremiumReservation:
allowed: bool allowed: bool
def needs_premium_quota( def needs_premium_quota(agent_config: AgentConfig | None, user_id: str | None) -> bool:
agent_config: AgentConfig | None, user_id: str | None
) -> bool:
return bool(agent_config is not None and user_id and agent_config.is_premium) 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] request_id = _uuid.uuid4().hex[:16]
litellm_params = agent_config.litellm_params or {} litellm_params = agent_config.litellm_params or {}
base_model = ( base_model = (
litellm_params.get("base_model") if isinstance(litellm_params, dict) else None (litellm_params.get("base_model") if isinstance(litellm_params, dict) else None)
) or agent_config.model_name or "" or agent_config.model_name
or ""
)
reserve_amount_micros = estimate_call_reserve_micros( reserve_amount_micros = estimate_call_reserve_micros(
base_model=base_model, base_model=base_model,
quota_reserve_tokens=agent_config.quota_reserve_tokens, quota_reserve_tokens=agent_config.quota_reserve_tokens,

View file

@ -6,8 +6,7 @@ import contextlib
import sys import sys
from typing import Any, Literal from typing import Any, Literal
from app.observability import metrics as ot_metrics from app.observability import metrics as ot_metrics, otel as ot
from app.observability import otel as ot
def open_chat_request_span( def open_chat_request_span(

View file

@ -15,8 +15,7 @@ from collections.abc import Iterator
from typing import Any, Literal from typing import Any, Literal
from app.agents.new_chat.errors import BusyError from app.agents.new_chat.errors import BusyError
from app.observability import metrics as ot_metrics from app.observability import metrics as ot_metrics, otel as ot
from app.observability import otel as ot
from app.services.new_streaming_service import VercelStreamingService 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.classifier import classify_stream_exception
from app.tasks.chat.streaming.errors.emitter import emit_stream_terminal_error from app.tasks.chat.streaming.errors.emitter import emit_stream_terminal_error

View file

@ -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 anything?" rather than guess whether ``""`` means silence or empty
output. Empty-string contents are normalized to ``None`` too.""" output. Empty-string contents are normalized to ``None`` too."""
no_ai = {"messages": [HumanMessage(content="just a question")]} 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=" ")]} empty_string = {"messages": [AIMessage(content=" ")]}
assert extract_final_assistant_message(no_ai) is None assert extract_final_assistant_message(no_ai) is None

View file

@ -33,7 +33,9 @@ async def test_with_retries_returns_result_and_attempts_one_on_first_success() -
assert calls == 1 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`` """A coroutine that fails twice then succeeds returns ``attempts=3``
(the actual attempt that produced the result). Locks the contract (the actual attempt that produced the result). Locks the contract
that the caller can distinguish first-try success from a recovery.""" that the caller can distinguish first-try success from a recovery."""

View file

@ -11,7 +11,9 @@ from app.automations.schemas.definition.plan_step import PlanStep
pytestmark = pytest.mark.unit 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 """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 fills in the rest with safe defaults so users don't have to write
out every section to get started.""" out every section to get started."""

View file

@ -32,7 +32,9 @@ def test_environment_finalizes_datetime_output_to_iso_string() -> None:
when emitting ``inputs.fired_at`` and other datetime values.""" when emitting ``inputs.fired_at`` and other datetime values."""
dt = datetime(2026, 5, 28, 14, 30, tzinfo=UTC) 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: def test_environment_finalizes_none_output_to_empty_string() -> None:

View file

@ -31,7 +31,7 @@ def test_action_definition_params_schema_reflects_params_model() -> None:
name="N", name="N",
description="D", description="D",
params_model=_Topic, 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 schema = definition.params_schema

View file

@ -29,7 +29,9 @@ class _Params(BaseModel):
def _trigger(type_: str = "test_trigger") -> TriggerDefinition: 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: def _action(type_: str = "test_action") -> ActionDefinition:
@ -38,7 +40,7 @@ def _action(type_: str = "test_action") -> ActionDefinition:
name="Test", name="Test",
description="Test action.", description="Test action.",
params_model=_Params, 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 = all_triggers()
snapshot.pop("snapshot_test") snapshot.pop("snapshot_test")
assert get_trigger("snapshot_test") is not None assert get_trigger("snapshot_test") is not None

View file

@ -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) winter_after = datetime(2026, 2, 15, 0, 0, tzinfo=UTC)
summer_after = datetime(2026, 4, 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) winter_fire = compute_next_fire_at(
summer_fire = compute_next_fire_at("0 9 * * *", "America/New_York", after=summer_after) "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 winter_fire == datetime(2026, 2, 15, 14, 0, tzinfo=UTC)
assert summer_fire == datetime(2026, 4, 15, 13, 0, tzinfo=UTC) assert summer_fire == datetime(2026, 4, 15, 13, 0, tzinfo=UTC)

View file

@ -33,7 +33,6 @@ import pytest
from app.agents.new_chat.context import SurfSenseContextSchema from app.agents.new_chat.context import SurfSenseContextSchema
from app.services.new_streaming_service import VercelStreamingService from app.services.new_streaming_service import VercelStreamingService
from app.tasks.chat.stream_new_chat import ( from app.tasks.chat.stream_new_chat import (
stream_new_chat as old_stream_new_chat, stream_new_chat as old_stream_new_chat,
stream_resume_chat as old_stream_resume_chat, stream_resume_chat as old_stream_resume_chat,
@ -152,7 +151,13 @@ class _FakeSurfsenseDoc:
"user_query, image_urls, docs, expected_title, expected_action", "user_query, image_urls, docs, 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"), (
"",
["data:image/png;base64,AAA"],
[],
"Understanding your request",
"Processing",
),
("", None, [], "Understanding your request", "Processing"), ("", None, [], "Understanding your request", "Processing"),
( (
"doc question", "doc question",
@ -209,9 +214,10 @@ def test_initial_thinking_step_collapses_many_doc_names() -> None:
def test_image_capability_passes_without_images() -> None: def test_image_capability_passes_without_images() -> None:
assert check_image_input_capability( assert (
user_image_data_urls=None, agent_config=None check_image_input_capability(user_image_data_urls=None, agent_config=None)
) is None is None
)
def test_image_capability_passes_when_capability_unknown() -> 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: def test_spawn_set_ai_responding_bg_noop_without_user_id() -> None:
async def _run() -> set[asyncio.Task]: async def _run() -> set[asyncio.Task]:
background: set[asyncio.Task] = set() background: set[asyncio.Task] = set()
spawn_set_ai_responding_bg( spawn_set_ai_responding_bg(chat_id=1, user_id=None, background_tasks=background)
chat_id=1, user_id=None, background_tasks=background
)
return background return background
bg = asyncio.run(_run()) bg = asyncio.run(_run())

View file

@ -221,10 +221,7 @@ export default async function FreeHubPage() {
<Separator className="my-12 max-w-4xl mx-auto" /> <Separator className="my-12 max-w-4xl mx-auto" />
{/* In-content ad: above the model table */} {/* In-content ad: above the model table */}
<aside <aside aria-label="Advertisement" className="max-w-4xl mx-auto mb-8 min-h-[100px]">
aria-label="Advertisement"
className="max-w-4xl mx-auto mb-8 min-h-[100px]"
>
<AdUnit slot={ADSENSE_SLOTS.freeHubInContent} /> <AdUnit slot={ADSENSE_SLOTS.freeHubInContent} />
</aside> </aside>
@ -353,10 +350,7 @@ export default async function FreeHubPage() {
<Separator className="my-12 max-w-4xl mx-auto" /> <Separator className="my-12 max-w-4xl mx-auto" />
{/* In-content ad: after CTA, before FAQ */} {/* In-content ad: after CTA, before FAQ */}
<aside <aside aria-label="Advertisement" className="max-w-3xl mx-auto my-8 min-h-[100px]">
aria-label="Advertisement"
className="max-w-3xl mx-auto my-8 min-h-[100px]"
>
<AdUnit slot={ADSENSE_SLOTS.freeHubBeforeFaq} /> <AdUnit slot={ADSENSE_SLOTS.freeHubBeforeFaq} />
</aside> </aside>

View file

@ -37,9 +37,9 @@ export default function PrivacyPolicy() {
</p> </p>
<p className="mt-4"> <p className="mt-4">
By accessing or using the Service, you acknowledge that you have read and understood 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 this Privacy Policy. If you do not agree with our policies and practices, do not use the
the Service. We may modify this policy from time to time; material changes will be Service. We may modify this policy from time to time; material changes will be reflected
reflected by updating the "Last updated" date above. by updating the "Last updated" date above.
</p> </p>
</section> </section>
@ -71,9 +71,9 @@ export default function PrivacyPolicy() {
Notion, Confluence, GitHub, and others) under the scopes you authorize. Notion, Confluence, GitHub, and others) under the scopes you authorize.
</li> </li>
<li> <li>
<strong>Billing Data</strong> includes information necessary to process payments <strong>Billing Data</strong> includes information necessary to process payments (such
(such as transaction identifiers and credit balances). Card details are handled by as transaction identifiers and credit balances). Card details are handled by our
our payment processor and are not stored on our servers. payment processor and are not stored on our servers.
</li> </li>
<li> <li>
<strong>Technical Data</strong> includes internet protocol (IP) address, browser type <strong>Technical Data</strong> includes internet protocol (IP) address, browser type
@ -126,8 +126,8 @@ export default function PrivacyPolicy() {
incidents. incidents.
</li> </li>
<li> <li>
To communicate with you about product updates, security notices, support requests, To communicate with you about product updates, security notices, support requests, and
and (with your consent where required) marketing. (with your consent where required) marketing.
</li> </li>
<li> <li>
To serve and measure advertising on pages where ads are shown (currently, our free To serve and measure advertising on pages where ads are shown (currently, our free
@ -141,8 +141,8 @@ export default function PrivacyPolicy() {
<h2 className="text-2xl font-semibold mb-4">4. Cookies and Tracking Technologies</h2> <h2 className="text-2xl font-semibold mb-4">4. Cookies and Tracking Technologies</h2>
<p> <p>
We and our partners use cookies, local storage, and similar technologies to operate the We and our partners use cookies, local storage, and similar technologies to operate the
Service, remember your preferences, measure usage, and serve advertising. The Service, remember your preferences, measure usage, and serve advertising. The categories
categories include: include:
</p> </p>
<ul className="list-disc pl-6 my-4 space-y-2"> <ul className="list-disc pl-6 my-4 space-y-2">
<li> <li>
@ -179,9 +179,9 @@ export default function PrivacyPolicy() {
</p> </p>
<ul className="list-disc pl-6 my-4 space-y-2"> <ul className="list-disc pl-6 my-4 space-y-2">
<li> <li>
Google, as a third-party vendor, uses cookies (including the DoubleClick DART Google, as a third-party vendor, uses cookies (including the DoubleClick DART cookie)
cookie) to serve ads to you based on your visits to our Service and other websites to serve ads to you based on your visits to our Service and other websites on the
on the Internet. Internet.
</li> </li>
<li> <li>
Google's use of advertising cookies enables it and its partners to serve ads to you Google's use of advertising cookies enables it and its partners to serve ads to you
@ -195,14 +195,12 @@ export default function PrivacyPolicy() {
<a href="https://www.youronlinechoices.com/">youronlinechoices.com</a> (EU). <a href="https://www.youronlinechoices.com/">youronlinechoices.com</a> (EU).
</li> </li>
<li> <li>
For users in the European Economic Area, the United Kingdom, and Switzerland, we For users in the European Economic Area, the United Kingdom, and Switzerland, we use a
use a Google-certified Consent Management Platform to obtain your consent for Google-certified Consent Management Platform to obtain your consent for personalized
personalized advertising before such cookies are set. You may change or withdraw advertising before such cookies are set. You may change or withdraw your consent at
your consent at any time through the consent banner. any time through the consent banner.
</li>
<li>
We do not knowingly serve personalized advertising to children. See Section 11.
</li> </li>
<li>We do not knowingly serve personalized advertising to children. See Section 11.</li>
</ul> </ul>
<p className="mt-4"> <p className="mt-4">
For more information about how Google uses data when you use our Service, see{" "} For more information about how Google uses data when you use our Service, see{" "}
@ -217,8 +215,8 @@ export default function PrivacyPolicy() {
<h2 className="text-2xl font-semibold mb-4">6. Data Security</h2> <h2 className="text-2xl font-semibold mb-4">6. Data Security</h2>
<p> <p>
We implement technical and organizational measures designed to protect your personal We implement technical and organizational measures designed to protect your personal
data against accidental loss, unauthorized access, alteration, and disclosure. Access data against accidental loss, unauthorized access, alteration, and disclosure. Access to
to personal data is limited to personnel who need it to operate the Service. personal data is limited to personnel who need it to operate the Service.
</p> </p>
<p className="mt-4"> <p className="mt-4">
No system can be guaranteed to be fully secure. We cannot guarantee that personal data No system can be guaranteed to be fully secure. We cannot guarantee that personal data
@ -232,10 +230,10 @@ export default function PrivacyPolicy() {
<p> <p>
We retain personal data only for as long as necessary to provide the Service and to 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 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 for the life of your account; you can request deletion at any time. Aggregated data that
that no longer identifies you may be retained indefinitely for analytics and product 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 improvement purposes. Anonymous chat sessions on our free pages are not retained in any
any user-linked database. user-linked database.
</p> </p>
</section> </section>
@ -243,8 +241,7 @@ export default function PrivacyPolicy() {
<h2 className="text-2xl font-semibold mb-4">8. Third-Party Services</h2> <h2 className="text-2xl font-semibold mb-4">8. Third-Party Services</h2>
<p> <p>
We rely on the following categories of third-party processors and providers to operate 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 the Service. Each is bound by its own privacy policy, which we encourage you to review:
review:
</p> </p>
<ul className="list-disc pl-6 my-4 space-y-2"> <ul className="list-disc pl-6 my-4 space-y-2">
<li> <li>
@ -261,9 +258,9 @@ export default function PrivacyPolicy() {
<strong>Advertising</strong>: Google AdSense (see Section 5). <strong>Advertising</strong>: Google AdSense (see Section 5).
</li> </li>
<li> <li>
<strong>Large language model providers</strong>: OpenAI, Anthropic, Google, and <strong>Large language model providers</strong>: OpenAI, Anthropic, Google, and other
other LLM providers process the prompts and content you submit to the Service in LLM providers process the prompts and content you submit to the Service in order to
order to generate responses. generate responses.
</li> </li>
<li> <li>
<strong>Integration providers</strong>: When you explicitly connect a third-party <strong>Integration providers</strong>: When you explicitly connect a third-party
@ -278,9 +275,7 @@ export default function PrivacyPolicy() {
</section> </section>
<section className="mb-8"> <section className="mb-8">
<h2 className="text-2xl font-semibold mb-4"> <h2 className="text-2xl font-semibold mb-4">9. Your Legal Rights (Including GDPR)</h2>
9. Your Legal Rights (Including GDPR)
</h2>
<p> <p>
Subject to applicable law, you have the following rights in relation to your personal Subject to applicable law, you have the following rights in relation to your personal
data: data:
@ -314,17 +309,17 @@ export default function PrivacyPolicy() {
</p> </p>
<ul className="list-disc pl-6 my-4 space-y-2"> <ul className="list-disc pl-6 my-4 space-y-2">
<li> <li>
The right to know what categories of personal information we have collected about The right to know what categories of personal information we have collected about you
you and how it is used and shared. and how it is used and shared.
</li> </li>
<li>The right to delete personal information we have collected from you.</li> <li>The right to delete personal information we have collected from you.</li>
<li>The right to correct inaccurate personal information.</li> <li>The right to correct inaccurate personal information.</li>
<li> <li>
The right to opt out of the "sale" or "sharing" of personal information for 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, cross-context behavioral advertising. We do not sell personal data; however,
advertising cookies set by Google AdSense may be considered "sharing" under advertising cookies set by Google AdSense may be considered "sharing" under California
California law. To opt out, you can use the consent controls described in Section 5 law. To opt out, you can use the consent controls described in Section 5 or enable a
or enable a Global Privacy Control (GPC) signal in your browser, which we honor. Global Privacy Control (GPC) signal in your browser, which we honor.
</li> </li>
<li>The right not to be discriminated against for exercising your privacy rights.</li> <li>The right not to be discriminated against for exercising your privacy rights.</li>
</ul> </ul>
@ -337,33 +332,32 @@ export default function PrivacyPolicy() {
<h2 className="text-2xl font-semibold mb-4">11. Children's Privacy</h2> <h2 className="text-2xl font-semibold mb-4">11. Children's Privacy</h2>
<p> <p>
The Service is not directed to children under 13 (or under 16 in the EEA, UK, and 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 Switzerland). We do not knowingly collect personal data from children. If you believe a
a child has provided us with personal data, please contact us and we will take steps child has provided us with personal data, please contact us and we will take steps to
to delete it. We do not knowingly serve personalized advertising to children. delete it. We do not knowingly serve personalized advertising to children.
</p> </p>
</section> </section>
<section className="mb-8"> <section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">12. Changes to This Policy</h2> <h2 className="text-2xl font-semibold mb-4">12. Changes to This Policy</h2>
<p> <p>
We may update this Privacy Policy from time to time to reflect changes in our We may update this Privacy Policy from time to time to reflect changes in our practices,
practices, technology, legal requirements, or for other operational reasons. When we technology, legal requirements, or for other operational reasons. When we make material
make material changes, we will update the "Last updated" date at the top of this page changes, we will update the "Last updated" date at the top of this page and, where
and, where appropriate, provide additional notice (such as an in-product notification appropriate, provide additional notice (such as an in-product notification or email).
or email). Your continued use of the Service after the updated policy becomes Your continued use of the Service after the updated policy becomes effective constitutes
effective constitutes your acceptance of the revised policy. your acceptance of the revised policy.
</p> </p>
</section> </section>
<section className="mb-8"> <section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">13. Contact Us</h2> <h2 className="text-2xl font-semibold mb-4">13. Contact Us</h2>
<p> <p>
If you have questions about this Privacy Policy or our privacy practices, or if you If you have questions about this Privacy Policy or our privacy practices, or if you want
want to exercise any of your rights, please contact us at: to exercise any of your rights, please contact us at:
</p> </p>
<p className="mt-2"> <p className="mt-2">
<strong>Email:</strong>{" "} <strong>Email:</strong> <a href="mailto:rohan@surfsense.com">rohan@surfsense.com</a>
<a href="mailto:rohan@surfsense.com">rohan@surfsense.com</a>
</p> </p>
</section> </section>
</div> </div>

View file

@ -1,10 +1,10 @@
import { mustGetQuery } from "@rocicorp/zero"; import { mustGetQuery } from "@rocicorp/zero";
import { handleQueryRequest } from "@rocicorp/zero/server"; import { handleQueryRequest } from "@rocicorp/zero/server";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { BACKEND_URL } from "@/lib/env-config";
import type { Context } from "@/types/zero"; import type { Context } from "@/types/zero";
import { queries } from "@/zero/queries"; import { queries } from "@/zero/queries";
import { schema } from "@/zero/schema"; import { schema } from "@/zero/schema";
import { BACKEND_URL } from "@/lib/env-config";
const backendURL = BACKEND_URL; const backendURL = BACKEND_URL;

View file

@ -16,10 +16,7 @@ interface AutomationEditContentProps {
* structure but gates on ``canUpdate`` instead of ``canRead``: a user who * structure but gates on ``canUpdate`` instead of ``canRead``: a user who
* can read but not update is bounced to the access-denied panel. * can read but not update is bounced to the access-denied panel.
*/ */
export function AutomationEditContent({ export function AutomationEditContent({ searchSpaceId, automationId }: AutomationEditContentProps) {
searchSpaceId,
automationId,
}: AutomationEditContentProps) {
const perms = useAutomationPermissions(); const perms = useAutomationPermissions();
const validId = Number.isInteger(automationId) && automationId > 0; const validId = Number.isInteger(automationId) && automationId > 0;
const { data: automation, isLoading, error } = useAutomation(validId ? automationId : undefined); const { data: automation, isLoading, error } = useAutomation(validId ? automationId : undefined);

View file

@ -9,10 +9,7 @@ import { JsonView } from "@/components/json-view";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { import { type Automation, automationUpdateRequest } from "@/contracts/types/automation.types";
type Automation,
automationUpdateRequest,
} from "@/contracts/types/automation.types";
interface AutomationEditFormProps { interface AutomationEditFormProps {
automation: Automation; automation: Automation;

View file

@ -69,11 +69,11 @@ import { documentsApiService } from "@/lib/apis/documents-api.service";
import { getBearerToken } from "@/lib/auth-utils"; import { getBearerToken } from "@/lib/auth-utils";
import { type ChatFlow, classifyChatError } from "@/lib/chat/chat-error-classifier"; import { type ChatFlow, classifyChatError } from "@/lib/chat/chat-error-classifier";
import { tagPreAcceptSendFailure, toHttpResponseError } from "@/lib/chat/chat-request-errors"; import { tagPreAcceptSendFailure, toHttpResponseError } from "@/lib/chat/chat-request-errors";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { import {
convertToThreadMessage, convertToThreadMessage,
reconcileInterruptedAssistantMessages, reconcileInterruptedAssistantMessages,
} from "@/lib/chat/message-utils"; } from "@/lib/chat/message-utils";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { import {
isPodcastGenerating, isPodcastGenerating,
looksLikePodcastRequest, looksLikePodcastRequest,
@ -110,6 +110,7 @@ import {
extractUserTurnForNewChatApi, extractUserTurnForNewChatApi,
type NewChatUserImagePayload, type NewChatUserImagePayload,
} from "@/lib/chat/user-turn-api-parts"; } from "@/lib/chat/user-turn-api-parts";
import { BACKEND_URL } from "@/lib/env-config";
import { NotFoundError } from "@/lib/error"; import { NotFoundError } from "@/lib/error";
import { import {
trackChatBlocked, trackChatBlocked,
@ -119,7 +120,7 @@ import {
trackChatResponseReceived, trackChatResponseReceived,
} from "@/lib/posthog/events"; } from "@/lib/posthog/events";
import Loading from "../loading"; import Loading from "../loading";
import { BACKEND_URL } from "@/lib/env-config";
const MobileEditorPanel = dynamic( const MobileEditorPanel = dynamic(
() => () =>
import("@/components/editor-panel/editor-panel").then((m) => ({ 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_folder_ids: regenerateFolderIds.length > 0 ? regenerateFolderIds : undefined,
mentioned_connector_ids: mentioned_connector_ids:
regenerateConnectors.length > 0 ? regenerateConnectors.map((d) => d.id) : undefined, regenerateConnectors.length > 0 ? regenerateConnectors.map((d) => d.id) : undefined,
mentioned_connectors: mentioned_connectors: regenerateConnectors.length > 0 ? regenerateConnectors : undefined,
regenerateConnectors.length > 0 ? regenerateConnectors : undefined,
// Full mention metadata for the regenerate-specific // Full mention metadata for the regenerate-specific
// source list. Only meaningful for edit (the BE only // source list. Only meaningful for edit (the BE only
// re-persists a user row when ``user_query`` is set); // re-persists a user row when ``user_query`` is set);
// reload reuses the original turn's mentioned_documents. // reload reuses the original turn's mentioned_documents.
mentioned_documents: mentioned_documents: sourceMentionedDocs.length > 0 ? sourceMentionedDocs : undefined,
sourceMentionedDocs.length > 0 ? sourceMentionedDocs : undefined,
}; };
if (isEdit) { if (isEdit) {
requestBody.user_images = editExtras?.userImages ?? []; requestBody.user_images = editExtras?.userImages ?? [];

View file

@ -31,7 +31,7 @@ import {
deleteMemberMutationAtom, deleteMemberMutationAtom,
updateMemberMutationAtom, updateMemberMutationAtom,
} from "@/atoms/members/members-mutation.atoms"; } 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 { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,

View file

@ -42,11 +42,11 @@ export const myAccessAtom = atomWithQuery((get) => {
/** /**
* Helper function to check if the current user has a specific permission. * Helper function to check if the current user has a specific permission.
* *
* @param access - The access object from useAtomValue(myAccessAtom) * @param access - The access object from useAtomValue(myAccessAtom)
* @param permission - The permission string to check * @param permission - The permission string to check
* @returns boolean indicating if the user has the permission * @returns boolean indicating if the user has the permission
* *
* @example * @example
* const access = useAtomValue(myAccessAtom); * const access = useAtomValue(myAccessAtom);
* if (canPerform(access, 'manage_members')) { ... } * if (canPerform(access, 'manage_members')) { ... }
@ -63,10 +63,10 @@ export function canPerform(
/** /**
* Hook wrapper for canPerform that reads from myAccessAtom internally. * Hook wrapper for canPerform that reads from myAccessAtom internally.
* Use this if you want to avoid calling useAtomValue(myAccessAtom) separately. * Use this if you want to avoid calling useAtomValue(myAccessAtom) separately.
* *
* @param permission - The permission string to check * @param permission - The permission string to check
* @returns boolean indicating if the user has the permission * @returns boolean indicating if the user has the permission
* *
* @example * @example
* const canManageMembers = usePermissionGate('manage_members'); * const canManageMembers = usePermissionGate('manage_members');
*/ */

View file

@ -13,8 +13,8 @@ import {
CheckIcon, CheckIcon,
ClipboardPaste, ClipboardPaste,
CopyIcon, CopyIcon,
DownloadIcon,
Dot, Dot,
DownloadIcon,
ExternalLink, ExternalLink,
Globe, Globe,
MessageCircleReply, MessageCircleReply,

View file

@ -6,14 +6,13 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { EnumConnectorName } from "@/contracts/enums/connector"; import { EnumConnectorName } from "@/contracts/enums/connector";
import { useApiKey } from "@/hooks/use-api-key"; import { useApiKey } from "@/hooks/use-api-key";
import { BACKEND_URL } from "@/lib/env-config";
import { getConnectorBenefits } from "../connector-benefits"; import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index"; import type { ConnectFormProps } from "../index";
import { BACKEND_URL } from "@/lib/env-config";
const PLUGIN_RELEASES_URL = const PLUGIN_RELEASES_URL =
"https://github.com/MODSetter/SurfSense/releases?q=obsidian&expanded=true"; "https://github.com/MODSetter/SurfSense/releases?q=obsidian&expanded=true";
/** /**
* Obsidian connect form for the plugin-only architecture. * Obsidian connect form for the plugin-only architecture.
* *

View file

@ -9,8 +9,8 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { authenticatedFetch } from "@/lib/auth-utils"; import { authenticatedFetch } from "@/lib/auth-utils";
import type { ConnectorConfigProps } from "../index";
import { BACKEND_URL } from "@/lib/env-config"; import { BACKEND_URL } from "@/lib/env-config";
import type { ConnectorConfigProps } from "../index";
export interface CirclebackConfigProps extends ConnectorConfigProps { export interface CirclebackConfigProps extends ConnectorConfigProps {
onNameChange?: (name: string) => void; onNameChange?: (name: string) => void;
} }

View file

@ -23,6 +23,7 @@ import { LIVE_CONNECTOR_TYPES } from "../../constants/connector-constants";
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab"; import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
import { MCPServiceConfig } from "../components/mcp-service-config"; import { MCPServiceConfig } from "../components/mcp-service-config";
import { getConnectorConfigComponent } from "../index"; import { getConnectorConfigComponent } from "../index";
const VISION_LLM_CONNECTOR_TYPES = new Set<SearchSourceConnector["connector_type"]>([ const VISION_LLM_CONNECTOR_TYPES = new Set<SearchSourceConnector["connector_type"]>([
EnumConnectorName.GOOGLE_DRIVE_CONNECTOR, EnumConnectorName.GOOGLE_DRIVE_CONNECTOR,
EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR, EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,

View file

@ -18,6 +18,7 @@ import { cn } from "@/lib/utils";
import { LIVE_CONNECTOR_TYPES } from "../constants/connector-constants"; import { LIVE_CONNECTOR_TYPES } from "../constants/connector-constants";
import { useConnectorStatus } from "../hooks/use-connector-status"; import { useConnectorStatus } from "../hooks/use-connector-status";
import { getConnectorDisplayName } from "../tabs/all-connectors-tab"; import { getConnectorDisplayName } from "../tabs/all-connectors-tab";
interface ConnectorAccountsListViewProps { interface ConnectorAccountsListViewProps {
connectorType: string; connectorType: string;
connectorTitle: string; connectorTitle: string;

View file

@ -97,7 +97,12 @@ interface InlineMentionEditorProps {
onActionClose?: () => void; onActionClose?: () => void;
onSubmit?: () => void; onSubmit?: () => void;
onChange?: (text: string, docs: MentionedDocument[]) => 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; onKeyDown?: (e: React.KeyboardEvent) => void;
disabled?: boolean; disabled?: boolean;
className?: string; className?: string;
@ -171,9 +176,10 @@ const MentionElement: FC<PlateElementProps<MentionElementNode>> = ({
{isFolder ? ( {isFolder ? (
<FolderIcon className="h-3 w-3" /> <FolderIcon className="h-3 w-3" />
) : isConnector ? ( ) : isConnector ? (
getConnectorIcon(element.connector_type ?? element.document_type ?? "UNKNOWN", "h-3 w-3") ?? ( (getConnectorIcon(
<PlugIcon className="h-3 w-3" /> element.connector_type ?? element.document_type ?? "UNKNOWN",
) "h-3 w-3"
) ?? <PlugIcon className="h-3 w-3" />)
) : ( ) : (
getConnectorIcon(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(); const rect = range.getClientRects()[0] ?? range.getBoundingClientRect();
if (rect.width > 0 || rect.height > 0) return rectToAnchor(rect); 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(); const fallbackRange = range.cloneRange();
fallbackRange.setStart(range.startContainer, range.startOffset - 1); fallbackRange.setStart(range.startContainer, range.startOffset - 1);
fallbackRange.setEnd(range.startContainer, range.startOffset); fallbackRange.setEnd(range.startContainer, range.startOffset);

View file

@ -68,11 +68,6 @@ import {
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { UserMessage } from "@/components/assistant-ui/user-message"; import { UserMessage } from "@/components/assistant-ui/user-message";
import { ComposerSuggestionPopoverContent } from "@/components/new-chat/composer-suggestion-popup"; 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 { PromptPicker, type PromptPickerRef } from "@/components/new-chat/prompt-picker";
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -112,6 +107,11 @@ import { captureDisplayToPngDataUrl } from "@/lib/chat/display-media-capture";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key"; import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { slideoutOpenedTickAtom } from "@/lib/layout-events"; import { slideoutOpenedTickAtom } from "@/lib/layout-events";
import { cn } from "@/lib/utils"; 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"; 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 handleActionTrigger = useCallback(
const anchorPoint = getComposerSuggestionAnchorPoint( (trigger: SuggestionTriggerInfo) => {
trigger.anchorRect, const anchorPoint = getComposerSuggestionAnchorPoint(
clipboardInitialText ? "bottom" : "top" trigger.anchorRect,
); clipboardInitialText ? "bottom" : "top"
if (!anchorPoint) { );
setShowPromptPicker(false); if (!anchorPoint) {
setActionQuery(""); setShowPromptPicker(false);
setSuggestionAnchorPoint(null); setActionQuery("");
return; setSuggestionAnchorPoint(null);
} return;
setSuggestionAnchorPoint((current) => current ?? anchorPoint); }
setShowPromptPicker(true); setSuggestionAnchorPoint((current) => current ?? anchorPoint);
setActionQuery(trigger.query); setShowPromptPicker(true);
}, [clipboardInitialText]); setActionQuery(trigger.query);
},
[clipboardInitialText]
);
const handleActionClose = useCallback(() => { const handleActionClose = useCallback(() => {
if (showPromptPicker) { if (showPromptPicker) {
@ -754,7 +757,12 @@ const Composer: FC = () => {
]); ]);
const handleDocumentRemove = useCallback( 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) => { setMentionedDocuments((prev) => {
const removedKey = getMentionDocKey({ const removedKey = getMentionDocKey({
id: docId, id: docId,
@ -768,27 +776,30 @@ const Composer: FC = () => {
[setMentionedDocuments] [setMentionedDocuments]
); );
const handleDocumentsMention = useCallback((mentions: MentionedDocumentInfo[]) => { const handleDocumentsMention = useCallback(
const parsedSearchSpaceId = Number(search_space_id); (mentions: MentionedDocumentInfo[]) => {
const editorMentionedDocs = editorRef.current?.getMentionedDocuments() ?? []; const parsedSearchSpaceId = Number(search_space_id);
const editorDocKeys = new Set(editorMentionedDocs.map((doc) => getMentionDocKey(doc))); const editorMentionedDocs = editorRef.current?.getMentionedDocuments() ?? [];
const editorDocKeys = new Set(editorMentionedDocs.map((doc) => getMentionDocKey(doc)));
for (const mention of mentions) { for (const mention of mentions) {
const key = getMentionDocKey(mention); const key = getMentionDocKey(mention);
if (editorDocKeys.has(key)) continue; if (editorDocKeys.has(key)) continue;
editorRef.current?.insertMentionChip(mention); editorRef.current?.insertMentionChip(mention);
if (Number.isFinite(parsedSearchSpaceId)) { if (Number.isFinite(parsedSearchSpaceId)) {
promoteRecentMention(parsedSearchSpaceId, mention); 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 // Atom is reconciled by ``handleEditorChange`` via the editor's
// onChange — no second write path here. // onChange — no second write path here.
setMentionQuery(""); setMentionQuery("");
setSuggestionAnchorPoint(null); setSuggestionAnchorPoint(null);
}, [search_space_id]); },
[search_space_id]
);
useEffect(() => { useEffect(() => {
const editor = editorRef.current; const editor = editorRef.current;

View file

@ -104,9 +104,9 @@ const UserTextPart: FC = () => {
const icon = isFolder ? ( const icon = isFolder ? (
<FolderIcon className="size-3.5" /> <FolderIcon className="size-3.5" />
) : isConnector ? ( ) : isConnector ? (
getConnectorIcon(segment.doc.connector_type, "size-3.5") ?? ( (getConnectorIcon(segment.doc.connector_type, "size-3.5") ?? (
<Plug className="size-3.5" /> <Plug className="size-3.5" />
) ))
) : ( ) : (
getConnectorIcon(segment.doc.document_type ?? "UNKNOWN", "size-3.5") getConnectorIcon(segment.doc.document_type ?? "UNKNOWN", "size-3.5")
); );
@ -123,7 +123,9 @@ const UserTextPart: FC = () => {
: segment.doc.title : segment.doc.title
} }
onClick={ 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" className="mx-0.5"
/> />

View file

@ -34,6 +34,7 @@ import { useElectronAPI } from "@/hooks/use-platform";
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils"; import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
import { inferMonacoLanguageFromPath } from "@/lib/editor-language"; import { inferMonacoLanguageFromPath } from "@/lib/editor-language";
import { BACKEND_URL } from "@/lib/env-config"; import { BACKEND_URL } from "@/lib/env-config";
const PlateEditor = dynamic( const PlateEditor = dynamic(
() => import("@/components/editor/plate-editor").then((m) => ({ default: m.PlateEditor })), () => import("@/components/editor/plate-editor").then((m) => ({ default: m.PlateEditor })),
{ ssr: false, loading: () => <EditorPanelSkeleton /> } { ssr: false, loading: () => <EditorPanelSkeleton /> }

View file

@ -6,11 +6,12 @@ import { Button } from "@/components/ui/button";
import type { AnonModel, AnonQuotaResponse } from "@/contracts/types/anonymous-chat.types"; import type { AnonModel, AnonQuotaResponse } from "@/contracts/types/anonymous-chat.types";
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service"; import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
import { readSSEStream } from "@/lib/chat/streaming-state"; import { readSSEStream } from "@/lib/chat/streaming-state";
import { BACKEND_URL } from "@/lib/env-config";
import { trackAnonymousChatMessageSent } from "@/lib/posthog/events"; import { trackAnonymousChatMessageSent } from "@/lib/posthog/events";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { QuotaBar } from "./quota-bar"; import { QuotaBar } from "./quota-bar";
import { QuotaWarningBanner } from "./quota-warning-banner"; import { QuotaWarningBanner } from "./quota-warning-banner";
import { BACKEND_URL } from "@/lib/env-config";
interface Message { interface Message {
id: string; id: string;
role: "user" | "assistant"; role: "user" | "assistant";
@ -80,19 +81,16 @@ export function AnonymousChat({ model }: AnonymousChatProps) {
content: m.content, content: m.content,
})); }));
const response = await fetch( const response = await fetch(`${BACKEND_URL}/api/v1/public/anon-chat/stream`, {
`${BACKEND_URL}/api/v1/public/anon-chat/stream`, method: "POST",
{ headers: { "Content-Type": "application/json" },
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" }, body: JSON.stringify({
credentials: "include", model_slug: modelSlug,
body: JSON.stringify({ messages: chatHistory,
model_slug: modelSlug, }),
messages: chatHistory, signal: controller.signal,
}), });
signal: controller.signal,
}
);
if (!response.ok) { if (!response.ok) {
if (response.status === 429) { if (response.status === 429) {

View file

@ -193,11 +193,7 @@ export function SearchSpaceAvatar({
// If delete or settings handlers are provided, expose them through a dropdown menu. // If delete or settings handlers are provided, expose them through a dropdown menu.
if (onDelete || onSettings) { if (onDelete || onSettings) {
const trigger = ( const trigger = <DropdownMenuTrigger asChild>{avatarButton(true)}</DropdownMenuTrigger>;
<DropdownMenuTrigger asChild>
{avatarButton(true)}
</DropdownMenuTrigger>
);
return ( return (
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}> <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>

View file

@ -78,11 +78,11 @@ import { foldersApiService } from "@/lib/apis/folders-api.service";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { authenticatedFetch } from "@/lib/auth-utils"; import { authenticatedFetch } from "@/lib/auth-utils";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key"; import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { BACKEND_URL } from "@/lib/env-config";
import { uploadFolderScan } from "@/lib/folder-sync-upload"; import { uploadFolderScan } from "@/lib/folder-sync-upload";
import { getSupportedExtensionsSet } from "@/lib/supported-extensions"; import { getSupportedExtensionsSet } from "@/lib/supported-extensions";
import { queries } from "@/zero/queries/index"; import { queries } from "@/zero/queries/index";
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel"; import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
import { BACKEND_URL } from "@/lib/env-config";
const DesktopLocalTabContent = dynamic( const DesktopLocalTabContent = dynamic(
() => import("./DesktopLocalTabContent").then((mod) => mod.DesktopLocalTabContent), () => import("./DesktopLocalTabContent").then((mod) => mod.DesktopLocalTabContent),

View file

@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils"; import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
import { BACKEND_URL } from "@/lib/env-config"; import { BACKEND_URL } from "@/lib/env-config";
const LARGE_DOCUMENT_THRESHOLD = 2 * 1024 * 1024; // 2MB const LARGE_DOCUMENT_THRESHOLD = 2 * 1024 * 1024; // 2MB
interface DocumentContent { interface DocumentContent {

View file

@ -117,7 +117,10 @@ const ComposerSuggestionItem = React.forwardRef<
)); ));
ComposerSuggestionItem.displayName = "ComposerSuggestionItem"; ComposerSuggestionItem.displayName = "ComposerSuggestionItem";
function ComposerSuggestionSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) { function ComposerSuggestionSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return ( return (
<div className={cn("my-0.5 px-2.5", className)}> <div className={cn("my-0.5 px-2.5", className)}>
<Separator className="bg-popover-border" {...props} /> <Separator className="bg-popover-border" {...props} />

View file

@ -2,6 +2,7 @@
import { useQuery as useZeroQuery } from "@rocicorp/zero/react"; import { useQuery as useZeroQuery } from "@rocicorp/zero/react";
import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { import {
BookOpen, BookOpen,
ChevronLeft, ChevronLeft,
@ -22,7 +23,6 @@ import {
useState, useState,
} from "react"; } from "react";
import type { MentionedDocumentInfo } from "@/atoms/chat/mentioned-documents.atom"; import type { MentionedDocumentInfo } from "@/atoms/chat/mentioned-documents.atom";
import { useAtomValue } from "jotai";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms"; import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { getConnectorTitle } from "@/components/assistant-ui/connector-popup/constants/connector-constants"; import { getConnectorTitle } from "@/components/assistant-ui/connector-popup/constants/connector-constants";
import { getConnectorDisplayName } from "@/components/assistant-ui/connector-popup/tabs/all-connectors-tab"; import { getConnectorDisplayName } from "@/components/assistant-ui/connector-popup/tabs/all-connectors-tab";
@ -178,7 +178,9 @@ function useDebounced<T>(value: T, delay = DEBOUNCE_MS) {
return debounced; return debounced;
} }
function makeDocMention(doc: Pick<Document, "id" | "title" | "document_type">): MentionedDocumentInfo { function makeDocMention(
doc: Pick<Document, "id" | "title" | "document_type">
): MentionedDocumentInfo {
return { return {
id: doc.id, id: doc.id,
title: doc.title, title: doc.title,
@ -187,9 +189,10 @@ function makeDocMention(doc: Pick<Document, "id" | "title" | "document_type">):
}; };
} }
function makeFolderMention( function makeFolderMention(folder: {
folder: { id: number; title: string } id: number;
): Extract<MentionedDocumentInfo, { kind: "folder" }> { title: string;
}): Extract<MentionedDocumentInfo, { kind: "folder" }> {
return { return {
id: folder.id, id: folder.id,
title: folder.title, title: folder.title,
@ -319,24 +322,24 @@ export const DocumentMentionPicker = forwardRef<
useEffect(() => { useEffect(() => {
if (currentPage !== 0) return; if (currentPage !== 0) return;
const combinedDocs: Pick<Document, "id" | "title" | "document_type">[] = []; const combinedDocs: Pick<Document, "id" | "title" | "document_type">[] = [];
if (surfsenseDocs?.items) { if (surfsenseDocs?.items) {
for (const doc of surfsenseDocs.items) { for (const doc of surfsenseDocs.items) {
combinedDocs.push({ combinedDocs.push({
id: doc.id, id: doc.id,
title: doc.title, title: doc.title,
document_type: "SURFSENSE_DOCS", document_type: "SURFSENSE_DOCS",
}); });
}
} }
}
if (titleSearchResults?.items) { if (titleSearchResults?.items) {
combinedDocs.push(...titleSearchResults.items); combinedDocs.push(...titleSearchResults.items);
setHasMore(titleSearchResults.has_more); setHasMore(titleSearchResults.has_more);
} }
setAccumulatedDocuments(filterBySearchTerm(combinedDocs)); setAccumulatedDocuments(filterBySearchTerm(combinedDocs));
}, [titleSearchResults, surfsenseDocs, currentPage, filterBySearchTerm]); }, [titleSearchResults, surfsenseDocs, currentPage, filterBySearchTerm]);
const loadNextPage = useCallback(async () => { const loadNextPage = useCallback(async () => {
@ -352,9 +355,11 @@ export const DocumentMentionPicker = forwardRef<
page_size: PAGE_SIZE, page_size: PAGE_SIZE,
...(isSearchValid ? { title: debouncedSearch.trim() } : {}), ...(isSearchValid ? { title: debouncedSearch.trim() } : {}),
}; };
const response: SearchDocumentTitlesResponse = await documentsApiService.searchDocumentTitles({ const response: SearchDocumentTitlesResponse = await documentsApiService.searchDocumentTitles(
queryParams, {
}); queryParams,
}
);
setAccumulatedDocuments((prev) => [...prev, ...response.items]); setAccumulatedDocuments((prev) => [...prev, ...response.items]);
setHasMore(response.has_more); setHasMore(response.has_more);
@ -431,7 +436,13 @@ export const DocumentMentionPicker = forwardRef<
) )
.filter((mention): mention is MentionedDocumentInfo => mention !== null) .filter((mention): mention is MentionedDocumentInfo => mention !== null)
.slice(0, RECENTS_LIMIT), .slice(0, RECENTS_LIMIT),
[activeConnectors, hasHydratedRecentDocs, recentMentions, recentValidationDocuments, zeroFolders] [
activeConnectors,
hasHydratedRecentDocs,
recentMentions,
recentValidationDocuments,
zeroFolders,
]
); );
const selectedKeys = useMemo( const selectedKeys = useMemo(
@ -460,47 +471,46 @@ export const DocumentMentionPicker = forwardRef<
[visibleRecentMentions, selectedKeys] [visibleRecentMentions, selectedKeys]
); );
const rootNodes = useMemo<ComposerSuggestionNode<ResourceNodeValue>[]>( const rootNodes = useMemo<ComposerSuggestionNode<ResourceNodeValue>[]>(() => {
() => { const nodes: ComposerSuggestionNode<ResourceNodeValue>[] = [...recentRootNodes];
const nodes: ComposerSuggestionNode<ResourceNodeValue>[] = [...recentRootNodes]; if (showSurfsenseDocsRoot) {
if (showSurfsenseDocsRoot) { nodes.push({
nodes.push({ id: "surfsense-docs",
id: "surfsense-docs", label: "SurfSense Docs",
label: "SurfSense Docs", subtitle: "Browse product documentation",
subtitle: "Browse product documentation", icon: <BookOpen className="size-4" />,
icon: <BookOpen className="size-4" />, type: "branch",
type: "branch", value: { kind: "view", view: { kind: "surfsense-docs" } },
value: { kind: "view", view: { kind: "surfsense-docs" } }, });
}); }
} nodes.push(
nodes.push( {
{ id: "files-folders",
id: "files-folders", label: "Files & Folders",
label: "Files & Folders", subtitle: "Browse your knowledge base",
subtitle: "Browse your knowledge base", icon: <Files className="size-4" />,
icon: <Files className="size-4" />, type: "branch",
type: "branch", value: { kind: "view", view: { kind: "files-folders" } },
value: { kind: "view", view: { kind: "files-folders" } }, },
}, {
{ id: "connectors",
id: "connectors", label: "Connectors",
label: "Connectors", subtitle: activeConnectors.length
subtitle: activeConnectors.length ? "Choose the exact account for tool use"
? "Choose the exact account for tool use" : "No connected accounts yet",
: "No connected accounts yet",
icon: <Unplug className="size-4" />, icon: <Unplug className="size-4" />,
type: "branch", type: "branch",
disabled: activeConnectors.length === 0, disabled: activeConnectors.length === 0,
value: { kind: "view", view: { kind: "connectors" } }, value: { kind: "view", view: { kind: "connectors" } },
} }
); );
return nodes; return nodes;
}, }, [activeConnectors.length, recentRootNodes, showSurfsenseDocsRoot]);
[activeConnectors.length, recentRootNodes, showSurfsenseDocsRoot]
);
const searchNodes = useMemo<ComposerSuggestionNode<ResourceNodeValue>[]>(() => { const searchNodes = useMemo<ComposerSuggestionNode<ResourceNodeValue>[]>(() => {
const searchLower = (isSingleCharSearch ? deferredSearch : debouncedSearch).trim().toLowerCase(); const searchLower = (isSingleCharSearch ? deferredSearch : debouncedSearch)
.trim()
.toLowerCase();
const docNodes = actualDocuments.map((doc) => { const docNodes = actualDocuments.map((doc) => {
const mention = makeDocMention(doc); const mention = makeDocMention(doc);
return { return {
@ -619,7 +629,9 @@ export const DocumentMentionPicker = forwardRef<
id: getMentionDocKey(mention), id: getMentionDocKey(mention),
label: getConnectorDisplayName(connector.name), label: getConnectorDisplayName(connector.name),
subtitle: `${view.title} account`, subtitle: `${view.title} account`,
icon: getConnectorIcon(connector.connector_type, "size-4") ?? <Unplug className="size-4" />, icon: getConnectorIcon(connector.connector_type, "size-4") ?? (
<Unplug className="size-4" />
),
type: "item" as const, type: "item" as const,
disabled: selectedKeys.has(getMentionDocKey(mention)), disabled: selectedKeys.has(getMentionDocKey(mention)),
value: { kind: "mention" as const, mention }, value: { kind: "mention" as const, mention },
@ -733,7 +745,7 @@ export const DocumentMentionPicker = forwardRef<
icon={ icon={
<span className="-ml-0.5 flex size-4.5 items-center justify-center"> <span className="-ml-0.5 flex size-4.5 items-center justify-center">
<ChevronLeft className="size-3.5" /> <ChevronLeft className="size-3.5" />
</span> </span>
} }
> >
<span className="flex-1 truncate">{title}</span> <span className="flex-1 truncate">{title}</span>
@ -759,7 +771,7 @@ export const DocumentMentionPicker = forwardRef<
return ( return (
<Fragment key={node.id}> <Fragment key={node.id}>
{showRecentsSeparator ? <ComposerSuggestionSeparator /> : null} {showRecentsSeparator ? <ComposerSuggestionSeparator /> : null}
<ComposerSuggestionItem <ComposerSuggestionItem
ref={navigator.getItemRef(index)} ref={navigator.getItemRef(index)}
icon={node.icon} icon={node.icon}
selected={index === navigator.highlightedIndex} selected={index === navigator.highlightedIndex}
@ -776,11 +788,11 @@ export const DocumentMentionPicker = forwardRef<
{node.subtitle} {node.subtitle}
</span> </span>
) : null} ) : null}
</span> </span>
{node.type === "branch" ? ( {node.type === "branch" ? (
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" /> <ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
) : null} ) : null}
</ComposerSuggestionItem> </ComposerSuggestionItem>
</Fragment> </Fragment>
); );
})} })}

View file

@ -129,7 +129,9 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
{isLoading ? ( {isLoading ? (
<ComposerSuggestionSkeleton rows={8} mobileRows={8} /> <ComposerSuggestionSkeleton rows={8} mobileRows={8} />
) : isError ? ( ) : isError ? (
<ComposerSuggestionMessage variant="destructive">Failed to load prompts</ComposerSuggestionMessage> <ComposerSuggestionMessage variant="destructive">
Failed to load prompts
</ComposerSuggestionMessage>
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
<ComposerSuggestionMessage>No matching prompts</ComposerSuggestionMessage> <ComposerSuggestionMessage>No matching prompts</ComposerSuggestionMessage>
) : ( ) : (

View file

@ -12,9 +12,9 @@ import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { authenticatedFetch } from "@/lib/auth-utils"; import { authenticatedFetch } from "@/lib/auth-utils";
import { BACKEND_URL } from "@/lib/env-config";
import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cacheKeys } from "@/lib/query-client/cache-keys";
import { Spinner } from "../ui/spinner"; import { Spinner } from "../ui/spinner";
import { BACKEND_URL } from "@/lib/env-config";
interface GeneralSettingsManagerProps { interface GeneralSettingsManagerProps {
searchSpaceId: number; searchSpaceId: number;

View file

@ -20,10 +20,7 @@ interface PromptConfigManagerProps {
} }
export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps) { export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps) {
const { const { data: searchSpace, isLoading: loading } = useQuery({
data: searchSpace,
isLoading: loading,
} = useQuery({
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()), queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()),
queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }), queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }),
enabled: !!searchSpaceId, enabled: !!searchSpaceId,
@ -56,8 +53,7 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
}); });
toast.success("System instructions saved successfully"); toast.success("System instructions saved successfully");
} catch (error: unknown) { } catch (error: unknown) {
const message = const message = error instanceof Error ? error.message : "Failed to save system instructions";
error instanceof Error ? error.message : "Failed to save system instructions";
console.error("Error saving system instructions:", error); console.error("Error saving system instructions:", error);
toast.error(message); toast.error(message);
} }

View file

@ -16,6 +16,7 @@ import { baseApiService } from "@/lib/apis/base-api.service";
import { authenticatedFetch } from "@/lib/auth-utils"; import { authenticatedFetch } from "@/lib/auth-utils";
import { clearActivePodcastTaskId, setActivePodcastTaskId } from "@/lib/chat/podcast-state"; import { clearActivePodcastTaskId, setActivePodcastTaskId } from "@/lib/chat/podcast-state";
import { BACKEND_URL } from "@/lib/env-config"; import { BACKEND_URL } from "@/lib/env-config";
/** /**
* Zod schemas for runtime validation * Zod schemas for runtime validation
*/ */
@ -193,10 +194,10 @@ function PodcastPlayer({
} else { } else {
// Authenticated view - fetch audio and details in parallel // Authenticated view - fetch audio and details in parallel
const [audioResponse, details] = await Promise.all([ const [audioResponse, details] = await Promise.all([
authenticatedFetch( authenticatedFetch(`${BACKEND_URL}/api/v1/podcasts/${podcastId}/audio`, {
`${BACKEND_URL}/api/v1/podcasts/${podcastId}/audio`, method: "GET",
{ method: "GET", signal: controller.signal } signal: controller.signal,
), }),
baseApiService.get<unknown>(`/api/v1/podcasts/${podcastId}`), baseApiService.get<unknown>(`/api/v1/podcasts/${podcastId}`),
]); ]);

View file

@ -10,6 +10,7 @@ import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { baseApiService } from "@/lib/apis/base-api.service"; import { baseApiService } from "@/lib/apis/base-api.service";
import { authenticatedFetch } from "@/lib/auth-utils"; import { authenticatedFetch } from "@/lib/auth-utils";
import { BACKEND_URL } from "@/lib/env-config";
import { compileCheck, compileToComponent } from "@/lib/remotion/compile-check"; import { compileCheck, compileToComponent } from "@/lib/remotion/compile-check";
import { FPS } from "@/lib/remotion/constants"; import { FPS } from "@/lib/remotion/constants";
import { import {
@ -19,7 +20,6 @@ import {
type CompiledSlide, type CompiledSlide,
} from "./combined-player"; } from "./combined-player";
import { getPptxExportErrorToast, getVideoDownloadErrorToast } from "./errors"; import { getPptxExportErrorToast, getVideoDownloadErrorToast } from "./errors";
import { BACKEND_URL } from "@/lib/env-config";
const GenerateVideoPresentationArgsSchema = z.object({ const GenerateVideoPresentationArgsSchema = z.object({
source_content: z.string(), source_content: z.string(),

View file

@ -107,9 +107,7 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?:
setError(null); setError(null);
// Build URL with optional search_space_id query parameter // Build URL with optional search_space_id query parameter
const url = new URL( const url = new URL(`${BACKEND_URL}/api/v1/search-source-connectors`);
`${BACKEND_URL}/api/v1/search-source-connectors`
);
if (spaceId !== undefined) { if (spaceId !== undefined) {
url.searchParams.append("search_space_id", spaceId.toString()); url.searchParams.append("search_space_id", spaceId.toString());
} }
@ -169,9 +167,7 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?:
) => { ) => {
try { try {
// Add search_space_id as a query parameter // Add search_space_id as a query parameter
const url = new URL( const url = new URL(`${BACKEND_URL}/api/v1/search-source-connectors`);
`${BACKEND_URL}/api/v1/search-source-connectors`
);
url.searchParams.append("search_space_id", spaceId.toString()); url.searchParams.append("search_space_id", spaceId.toString());
const response = await authenticatedFetch(url.toString(), { const response = await authenticatedFetch(url.toString(), {
@ -283,9 +279,7 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?:
} }
const response = await authenticatedFetch( 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", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },

View file

@ -2,6 +2,7 @@
* Authentication utilities for handling token expiration and redirects * Authentication utilities for handling token expiration and redirects
*/ */
import { BACKEND_URL } from "@/lib/env-config"; import { BACKEND_URL } from "@/lib/env-config";
const REDIRECT_PATH_KEY = "surfsense_redirect_path"; const REDIRECT_PATH_KEY = "surfsense_redirect_path";
const BEARER_TOKEN_KEY = "surfsense_bearer_token"; const BEARER_TOKEN_KEY = "surfsense_bearer_token";
const REFRESH_TOKEN_KEY = "surfsense_refresh_token"; const REFRESH_TOKEN_KEY = "surfsense_refresh_token";

View file

@ -1,6 +1,6 @@
import type { ChatErrorKind, ChatErrorSeverity, ChatFlow } from "@/lib/chat/chat-error-classifier";
import type { ConnectorTelemetryMeta } from "@/lib/connector-telemetry"; import type { ConnectorTelemetryMeta } from "@/lib/connector-telemetry";
import { getConnectorTelemetryMeta } 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 * PostHog Analytics Event Definitions