diff --git a/surfsense_local/backend/modules/egress/service.py b/surfsense_local/backend/modules/egress/service.py index 1def318554..9852291e19 100644 --- a/surfsense_local/backend/modules/egress/service.py +++ b/surfsense_local/backend/modules/egress/service.py @@ -18,9 +18,9 @@ class EgressDeniedError(Exception): - def __init__(self, destination: str) -> None: + def __init__(self, destination: str, host: str | None = None) -> None: self.destination = destination - self.host = host_of(destination) + self.host = host or host_of(destination) super().__init__(f"sending data to {self.host} is off in Settings > Network") @@ -28,6 +28,13 @@ def host_of(destination: str) -> str: return HOSTS.get(destination) or destination.removeprefix(HOST_PREFIX) +def ollama_pull_host(model_name: str) -> str: + """Ollama fetches an `hf.co/` pull straight from Hugging Face, not its own registry.""" + if model_name.startswith("hf.co/"): + return "huggingface.co" + return HOSTS[OLLAMA_PULL] + + def is_destination(value: str) -> bool: return value in HOSTS or ( value.startswith(HOST_PREFIX) and len(value) > len(HOST_PREFIX) @@ -49,12 +56,14 @@ def _is_loopback(host: str) -> bool: return False -def require(session: Session, destination: str | None) -> None: +def require( + session: Session, destination: str | None, host: str | None = None +) -> None: if destination is None: return row = session.get(EgressDestination, destination) if row is None or not row.enabled: - raise EgressDeniedError(destination) + raise EgressDeniedError(destination, host) row.last_call_at = datetime.now(UTC) diff --git a/surfsense_local/backend/modules/llm/recommendations/router.py b/surfsense_local/backend/modules/llm/recommendations/router.py index 60fd38f67a..5927e7d843 100644 --- a/surfsense_local/backend/modules/llm/recommendations/router.py +++ b/surfsense_local/backend/modules/llm/recommendations/router.py @@ -8,6 +8,7 @@ from sqlalchemy.orm import Session from api.dependencies import SessionDep, transact +from modules.egress import service as egress from modules.llm.models import ModelRole, SelectedModel from modules.llm.recommendations.catalog import ( InsufficientDiskError, @@ -61,6 +62,7 @@ async def install_model( payload: InstallRequest, request: Request, service: CatalogServiceDep, + session: SessionDep, ) -> StreamingResponse: try: runtime, _model, plan = await service.preflight(payload.catalog_id) @@ -81,6 +83,13 @@ async def install_model( except RuntimeError as error: raise HTTPException(status.HTTP_409_CONFLICT, str(error)) from error + await transact( + session, + egress.require, + egress.OLLAMA_PULL, + egress.ollama_pull_host(plan.model_name), + ) + lock = service.install_lock(runtime.name) if lock.locked(): raise HTTPException( diff --git a/surfsense_local/backend/modules/llm/router.py b/surfsense_local/backend/modules/llm/router.py index d6d2e5adc6..f27d5b89ed 100644 --- a/surfsense_local/backend/modules/llm/router.py +++ b/surfsense_local/backend/modules/llm/router.py @@ -328,7 +328,12 @@ async def pull_model( service: CatalogServiceDep, session: SessionDep, ) -> StreamingResponse: - await transact(session, egress.require, egress.OLLAMA_PULL) + await transact( + session, + egress.require, + egress.OLLAMA_PULL, + egress.ollama_pull_host(payload.name), + ) lock = service.install_lock(store.name) if lock.locked(): raise HTTPException( diff --git a/surfsense_local/backend/tests/integration/llm/test_egress.py b/surfsense_local/backend/tests/integration/llm/test_egress.py index eb95d10250..be89e2aa1d 100644 --- a/surfsense_local/backend/tests/integration/llm/test_egress.py +++ b/surfsense_local/backend/tests/integration/llm/test_egress.py @@ -1,10 +1,15 @@ """Nothing leaves the machine until the user allows that destination.""" +import json +from pathlib import Path + import pytest from httpx import AsyncClient from sqlalchemy import Engine from modules.llm.models import ProviderConnection +from modules.llm.recommendations.dependencies import get_catalog_service +from shared.config import get_llm_settings from shared.db import create_session_factory pytestmark = pytest.mark.integration @@ -95,3 +100,71 @@ async def test_unknown_destination_is_rejected(client: AsyncClient) -> None: """Only destinations the app contacts can be toggled.""" reply = await client.put("/egress/keygen", json={"enabled": True}) assert reply.status_code == 422 + + +def _configure_llmfit(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + system = {"system": {"available_ram_gb": 16, "total_ram_gb": 16}} + fit = { + "models": [ + { + "name": "Qwen/Qwen3-8B", + "provider": "Qwen", + "parameter_count": "8B", + "use_case": "chat", + "fit_level": "good", + "score": 85, + "runtime": "llamacpp", + "run_mode": "gpu", + "best_quant": "Q4_K_M", + "memory_required_gb": 6, + "memory_available_gb": 16, + "disk_size_gb": 5.2, + "effective_context_length": 8192, + "capability_ids": ["tool_use"], + "ollama_name": "qwen3:8b", + "gguf_sources": [], + } + ] + } + executable = tmp_path / "llmfit" + executable.write_text( + "#!/usr/bin/env python3\n" + "import sys\n" + f"system = {json.dumps(system)!r}\n" + f"fit = {json.dumps(fit)!r}\n" + 'print("llmfit 1.1.11" if "--version" in sys.argv ' + 'else system if "system" in sys.argv else fit)\n' + ) + executable.chmod(0o755) + monkeypatch.setattr(get_llm_settings(), "llmfit_path", executable) + get_catalog_service.cache_clear() + + +async def test_catalog_install_is_refused_until_allowed( + client: AsyncClient, + ollama_server: str, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The catalog's own install route is gated the same as a manual pull.""" + _configure_llmfit(tmp_path, monkeypatch) + catalog = (await client.get("/llm/catalog?refresh=true")).json() + catalog_id = next( + row["catalog_id"] for row in catalog["curated"] if row["fit"] != "unknown" + ) + + refused = await client.post( + "/llm/install", json={"catalog_id": catalog_id, "select": False} + ) + assert refused.status_code == 403 + assert refused.json()["detail"]["destination"] == "ollama_pull" + assert (await _destinations(client))["ollama_pull"]["last_call_at"] is None + + await client.put("/egress/ollama_pull", json={"enabled": True}) + async with client.stream( + "POST", "/llm/install", json={"catalog_id": catalog_id, "select": False} + ) as reply: + assert reply.status_code == 200 + await reply.aread() + assert (await _destinations(client))["ollama_pull"]["last_call_at"] is not None + get_catalog_service.cache_clear() diff --git a/surfsense_local/backend/tests/integration/llm/test_routes.py b/surfsense_local/backend/tests/integration/llm/test_routes.py index 758fbee4e7..50a80d08a9 100644 --- a/surfsense_local/backend/tests/integration/llm/test_routes.py +++ b/surfsense_local/backend/tests/integration/llm/test_routes.py @@ -485,6 +485,7 @@ async def test_ranked_catalog_installs_and_selects_in_one_stream( assert [row["canonical_id"] for row in scanned_curated] == ["Qwen/Qwen3-8B"] catalog_id = scanned_curated[0]["catalog_id"] + await client.put("/egress/ollama_pull", json={"enabled": True}) events = [] async with client.stream( "POST", diff --git a/surfsense_local/backend/tests/unit/egress/test_service.py b/surfsense_local/backend/tests/unit/egress/test_service.py new file mode 100644 index 0000000000..cc5c3292b1 --- /dev/null +++ b/surfsense_local/backend/tests/unit/egress/test_service.py @@ -0,0 +1,15 @@ +import pytest + +from modules.egress import service as egress + +pytestmark = pytest.mark.unit + + +def test_a_library_pull_reports_the_ollama_registry() -> None: + """Ollama's own model library is served from its registry.""" + assert egress.ollama_pull_host("qwen3:8b") == "registry.ollama.ai" + + +def test_an_hf_co_fallback_pull_reports_hugging_face() -> None: + """Ollama fetches `hf.co/` pulls straight from Hugging Face, not its own registry.""" + assert egress.ollama_pull_host("hf.co/Qwen/Qwen3-8B-GGUF:Q4_K_M") == "huggingface.co" diff --git a/surfsense_local/frontend/src/components/ui/select.tsx b/surfsense_local/frontend/src/components/ui/select.tsx new file mode 100644 index 0000000000..adf3a48572 --- /dev/null +++ b/surfsense_local/frontend/src/components/ui/select.tsx @@ -0,0 +1,26 @@ +import type { ComponentProps } from "react" + +import { ChevronDownIcon } from "@/components/ui/icons" +import { cn } from "@/lib/utils" + +// A plain ` + {children} + + + + ) +} + +export { Select } diff --git a/surfsense_local/frontend/src/features/chat/chats-dialog.tsx b/surfsense_local/frontend/src/features/chat/chats-dialog.tsx index bb1dc6886a..0eab47d4ce 100644 --- a/surfsense_local/frontend/src/features/chat/chats-dialog.tsx +++ b/surfsense_local/frontend/src/features/chat/chats-dialog.tsx @@ -183,7 +183,6 @@ export function ChatsDialog({
{isLoading ? : null} diff --git a/surfsense_local/frontend/src/features/dashboard/dashboard-page.tsx b/surfsense_local/frontend/src/features/dashboard/dashboard-page.tsx index a4fd6e1bb8..839709f0c0 100644 --- a/surfsense_local/frontend/src/features/dashboard/dashboard-page.tsx +++ b/surfsense_local/frontend/src/features/dashboard/dashboard-page.tsx @@ -253,6 +253,9 @@ function WorkspaceDashboard({ void }) { + const checkboxId = useId() return (
  • @@ -41,13 +44,16 @@ function DestinationRow({ {lastCallAt ? : "never"}

    -
  • ) } @@ -88,7 +94,7 @@ export function NetworkSettings() { return ( {destinations.isLoading ? ( diff --git a/surfsense_local/frontend/src/features/license/license-settings.tsx b/surfsense_local/frontend/src/features/license/license-settings.tsx index ecd13161d4..c65b95d926 100644 --- a/surfsense_local/frontend/src/features/license/license-settings.tsx +++ b/surfsense_local/frontend/src/features/license/license-settings.tsx @@ -217,7 +217,7 @@ export function LicenseSettings() { return ( {loading ? (
    diff --git a/surfsense_local/frontend/src/features/settings/models-settings.tsx b/surfsense_local/frontend/src/features/settings/models-settings.tsx index c3ca354516..7d7122c86d 100644 --- a/surfsense_local/frontend/src/features/settings/models-settings.tsx +++ b/surfsense_local/frontend/src/features/settings/models-settings.tsx @@ -8,7 +8,7 @@ import { useModelSelection } from "@/features/model-selection/use-model-selectio import { SettingsSection } from "./settings-section" const DESCRIPTION = - "Download local models or choose the model SurfSense uses for new messages." + "Add models locally or via an OpenAI compatible endpoint." export function ModelsSettings({ onModelUnavailable, diff --git a/surfsense_local/frontend/src/features/sources/sources-panel.tsx b/surfsense_local/frontend/src/features/sources/sources-panel.tsx index 5d7706bb54..e27ff4b212 100644 --- a/surfsense_local/frontend/src/features/sources/sources-panel.tsx +++ b/surfsense_local/frontend/src/features/sources/sources-panel.tsx @@ -342,7 +342,7 @@ export function SourcesPanel({

    Sources

    diff --git a/surfsense_local/frontend/src/features/studio/artifact-list.tsx b/surfsense_local/frontend/src/features/studio/artifact-list.tsx index adae8e5b76..d3366eb990 100644 --- a/surfsense_local/frontend/src/features/studio/artifact-list.tsx +++ b/surfsense_local/frontend/src/features/studio/artifact-list.tsx @@ -390,7 +390,7 @@ export function ArtifactList({

    Artifacts

    diff --git a/surfsense_local/frontend/src/features/studio/podcast-brief-form.tsx b/surfsense_local/frontend/src/features/studio/podcast-brief-form.tsx index 0e5200dbe5..127ffbfd2c 100644 --- a/surfsense_local/frontend/src/features/studio/podcast-brief-form.tsx +++ b/surfsense_local/frontend/src/features/studio/podcast-brief-form.tsx @@ -1,9 +1,9 @@ -import { useId, type ComponentProps } from "react" +import { useId } from "react" import { Button } from "@/components/ui/button" import { PlusIcon, Trash2Icon } from "@/components/ui/icons" import { Input } from "@/components/ui/input" -import { cn } from "@/lib/utils" +import { Select } from "@/components/ui/select" import { MAX_SPEAKERS, @@ -197,7 +197,7 @@ export function PodcastBriefForm({ {brief.speakers.length > 1 ? (
    ) } - -function Select({ className, ...props }: ComponentProps<"select">) { - return ( - setPrompt(event.target.value)} + /> +
    +
    + +
    + +
    +
    -
    -
    -

    +

    +
    + {ready.length > 0 ? ( ) : null}
    - {ready.length === 0 ? ( -

    - Add and index a source first — only ready documents can be used. -

    - ) : ( -
    -
    - {ready.map((document) => { - const on = selected.has(document.id) - return ( - - ) - })} -
    + {on ? : null} + + + {document.title} + + + ) + })}
    - )} -
    - -
    -

    - Prompt (optional) -

    - setPrompt(event.target.value)} - /> +
    - -
    ) } @@ -249,6 +295,9 @@ function FormatCard({ export function StudioPanel({ workspaceId, documents, + selectedDocumentIds, + onSelectionChange, + onToggleAll, formats, isCreating, error, @@ -256,6 +305,9 @@ export function StudioPanel({ }: { workspaceId: number documents: WorkspaceDocument[] + selectedDocumentIds: number[] + onSelectionChange: (documentId: number, included: boolean) => void + onToggleAll: () => void formats: StudioFormat[] isCreating: boolean error: string | null @@ -293,7 +345,7 @@ export function StudioPanel({ if (!open) setFormat(null) }} > - + {selectedFormat ? ( <> @@ -307,6 +359,9 @@ export function StudioPanel({ workspaceId={workspaceId} format={selectedFormat.key} documents={documents} + selectedDocumentIds={selectedDocumentIds} + onSelectionChange={onSelectionChange} + onToggleAll={onToggleAll} isCreating={isCreating} onGenerate={(job) => { void onGenerate(job).then((created) => { diff --git a/surfsense_local/frontend/src/features/updates/update-settings.tsx b/surfsense_local/frontend/src/features/updates/update-settings.tsx index ce91c1c715..ea287ff7f8 100644 --- a/surfsense_local/frontend/src/features/updates/update-settings.tsx +++ b/surfsense_local/frontend/src/features/updates/update-settings.tsx @@ -5,6 +5,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip" +import { askEgress } from "@/features/egress/egress-prompt" import type { UpdateState } from "@/lib/api" import { @@ -33,19 +34,31 @@ function statusText(state: UpdateState) { export function UpdateSettings() { const updates = updatesBridge() const state = useUpdateState() - const { prefs } = useUpdatePrefs() + const { prefs, setAutomatic } = useUpdatePrefs() if (!updates || prefs === null) return null + // Installing is local and needs no permission. Checking asks github.com, and + // Settings > Network promises that call is refused until allowed -- so the + // first check asks, the same way the sidebar's does. + const onCheckClick = async () => { + if (prefs.automatic) return void updates.check() + const allowed = await askEgress({ + destination: "app_updates", + host: "github.com", + allow: () => setAutomatic(true), + }) + if (allowed) await updates.check() + } + const text = statusText(state) return (
    -

    Updates

    +

    App updates

    - Updates come from GitHub Releases and are free for everyone. SurfSense - never contacts them until you allow App updates under Network, which - is also what turns on the check at launch. + Free updates from GitHub Releases. SurfSense stays silent until you + allow App updates under Network, which also enables the launch check.

    {state.status === "error" ? (

    @@ -63,14 +76,10 @@ export function UpdateSettings() {