diff --git a/client/src/components/cos/tabs/schedule/PipelineStageConfig.jsx b/client/src/components/cos/tabs/schedule/PipelineStageConfig.jsx index b10f8397e4..fd9a89c01b 100644 --- a/client/src/components/cos/tabs/schedule/PipelineStageConfig.jsx +++ b/client/src/components/cos/tabs/schedule/PipelineStageConfig.jsx @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import { Link } from 'react-router'; import { effortAwareModelOptions, effortSurvivingModel, @@ -103,7 +104,10 @@ export default function PipelineStageConfig({ taskType, config, providers, onUpd

Managed Llama Prompt Guard 2 86M

Fixed, pinned, offline classifier. It scans complete external content before Stage 2 and never appears as a chat model or receives tools, MCP servers, repository files, or GitHub credentials.

-

Install or check readiness from Models → LLMs → Model Library.

+

+ Install or check readiness from{' '} + Models → LLMs → Abuse Guard. +

)} {!isSecurityStage && ( diff --git a/client/src/components/models/ModelAbuseGuardPanel.jsx b/client/src/components/models/ModelAbuseGuardPanel.jsx new file mode 100644 index 0000000000..3e0ee31739 --- /dev/null +++ b/client/src/components/models/ModelAbuseGuardPanel.jsx @@ -0,0 +1,214 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { CheckCircle2, Circle, Download, ExternalLink, ShieldCheck } from 'lucide-react'; +import toast from '../ui/Toast'; +import BrailleSpinner from '../BrailleSpinner'; +import PromptGuardHfAccessNotice from '../imageGen/PromptGuardHfAccessNotice.jsx'; +import { useHfTokenStatus } from '../../hooks/useHfTokenStatus'; +import { + cancelModelAbuseGuardInstall, + getModelAbuseGuardStatus, + installModelAbuseGuard, +} from '../../services/api'; +import socket from '../../services/socket'; + +const FALLBACK_STAGES = [ + { id: 'huggingface-token', label: 'Hugging Face access token', description: 'A read token plus gated-model approval on the Prompt Guard model card.' }, + { id: 'python', label: 'Host Python', description: 'A Python interpreter PortOS can use as the base for the dedicated runtime.' }, + { id: 'venv', label: 'Dedicated Prompt Guard runtime', description: 'A private virtualenv that never shares packages with image or video generation.' }, + { id: 'packages', label: 'Classifier packages', description: 'Pinned torch, transformers, safetensors, and huggingface_hub imports.' }, + { id: 'model', label: 'Pinned model snapshot', description: 'The five required Prompt Guard files from the pinned revision.' }, +]; + +function stagesFromStatus(status) { + return Array.isArray(status?.stages) && status.stages.length ? status.stages : FALLBACK_STAGES.map((stage) => { + if (stage.id === 'packages') return { ...stage, ready: status?.runtimeReady === true }; + if (stage.id === 'model') return { ...stage, ready: status?.modelCached === true }; + if (stage.id === 'venv') return { ...stage, ready: status?.venvReady === true }; + if (stage.id === 'python') return { ...stage, ready: status?.pythonAvailable === true }; + return { ...stage, ready: false }; + }); +} + +export default function ModelAbuseGuardPanel() { + const [guardStatus, setGuardStatus] = useState(null); + const [installing, setInstalling] = useState(false); + const [progressMsg, setProgressMsg] = useState(''); + const [installingStage, setInstallingStage] = useState(null); + const progressTimer = useRef(null); + const { present: tokenPresent, source: tokenSource, refresh: refreshToken } = useHfTokenStatus(); + + const loadGuardStatus = useCallback(() => ( + getModelAbuseGuardStatus({ silent: true }) + .then((res) => { + if (res) setGuardStatus(res); + return res; + }) + .catch(() => null) + ), []); + + useEffect(() => { loadGuardStatus(); }, [loadGuardStatus]); + + useEffect(() => { + const handleProgress = (data) => { + if (data?.scope !== 'security-guard') return; + clearTimeout(progressTimer.current); + if (data?.stage) setInstallingStage(data.stage); + setProgressMsg(data?.message || ''); + if (data?.event === 'complete') { + setInstalling(false); + setInstallingStage(null); + progressTimer.current = setTimeout(() => setProgressMsg(''), 3000); + loadGuardStatus(); + } + if (data?.event === 'error') { + setInstalling(false); + progressTimer.current = setTimeout(() => setProgressMsg(''), 5000); + loadGuardStatus(); + } + }; + socket.on('localLlm:progress', handleProgress); + return () => { + socket.off('localLlm:progress', handleProgress); + clearTimeout(progressTimer.current); + }; + }, [loadGuardStatus]); + + const installGuard = () => { + setInstalling(true); + setProgressMsg('Installing the dedicated guard…'); + return installModelAbuseGuard() + .then((result) => { + if (result?.ready === true) toast.success('Model-abuse guard installed and ready'); + return loadGuardStatus(); + }) + .catch(() => loadGuardStatus()) + .finally(() => { + setInstalling(false); + setInstallingStage(null); + }); + }; + + const cancelGuard = () => cancelModelAbuseGuardInstall({ silent: true }) + .then(loadGuardStatus) + .catch(() => null); + + const stages = stagesFromStatus(guardStatus).map((stage) => { + if (stage.id !== 'huggingface-token' || tokenPresent === null) return stage; + return { ...stage, ready: tokenPresent === true }; + }); + const currentStageId = installingStage || (installing ? stages.find((stage) => !stage.ready)?.id : null); + const overallReady = guardStatus?.ready === true; + + return ( +
+
+
+
+ {overallReady ? ( + Ready + ) : guardStatus ? ( + Not installed + ) : ( + Checking status… + )} +
+

+ Llama Prompt Guard 2 86M screens complete external issues, comments, and pull-request diffs before they reach a reasoning agent. It is a pinned local classifier with no chat, tools, MCP, or repository access; flagged or inconclusive content is withheld. +

+ { refreshToken(); loadGuardStatus(); }} + /> +
    + {stages.map((stage) => { + const current = installing && currentStageId === stage.id; + const waiting = installing && !stage.ready && currentStageId && currentStageId !== stage.id; + return ( +
  1. + +
    +
    +

    {stage.label}

    + + {stage.ready ? 'Ready' : current ? 'Installing…' : waiting ? 'Waiting' : 'Not ready'} + +
    +

    {stage.description}

    + {current && progressMsg && ( +

    {progressMsg}

    + )} +
    +
  2. + ); + })} +
+
+ {guardStatus?.name || 'Llama Prompt Guard 2 86M'} + · + 86M · offline · no tools + + Model card + +
+
+ {overallReady ? ( + Installed from the pinned model revision. + ) : installing ? ( + <> + Installing the dedicated guard… + + + ) : ( + + )} + {installing && progressMsg && !installingStage && ( + {progressMsg} + )} +
+
+ ); +} diff --git a/client/src/components/models/ModelAbuseGuardPanel.test.jsx b/client/src/components/models/ModelAbuseGuardPanel.test.jsx new file mode 100644 index 0000000000..ee1fc815ee --- /dev/null +++ b/client/src/components/models/ModelAbuseGuardPanel.test.jsx @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; + +vi.mock('../../services/api', () => ({ + getModelAbuseGuardStatus: vi.fn(), + getHfTokenStatus: vi.fn(), + installModelAbuseGuard: vi.fn(), + cancelModelAbuseGuardInstall: vi.fn(), +})); +vi.mock('../../services/socket', () => ({ + default: { on: vi.fn(), off: vi.fn() }, +})); +vi.mock('../ui/Toast', () => ({ + default: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }), +})); + +import { + cancelModelAbuseGuardInstall, + getHfTokenStatus, + getModelAbuseGuardStatus, + installModelAbuseGuard, +} from '../../services/api'; +import socket from '../../services/socket'; +import ModelAbuseGuardPanel from './ModelAbuseGuardPanel'; + +const STAGES = [ + { id: 'huggingface-token', label: 'Hugging Face access token', description: 'A read token plus gated-model approval.', ready: true }, + { id: 'python', label: 'Host Python', description: 'A Python interpreter for the dedicated runtime.', ready: true }, + { id: 'venv', label: 'Dedicated Prompt Guard runtime', description: 'A private virtualenv.', ready: false }, + { id: 'packages', label: 'Classifier packages', description: 'Pinned classifier imports.', ready: false }, + { id: 'model', label: 'Pinned model snapshot', description: 'The five required Prompt Guard files.', ready: false }, +]; + +beforeEach(() => { + vi.clearAllMocks(); + getModelAbuseGuardStatus.mockResolvedValue({ + id: 'llama-prompt-guard-2-86m', + name: 'Llama Prompt Guard 2 86M', + sourceUrl: 'https://huggingface.co/meta-llama/Llama-Prompt-Guard-2-86M', + ready: false, + modelCached: false, + runtimeReady: false, + pythonAvailable: true, + venvReady: false, + stages: STAGES, + }); + getHfTokenStatus.mockResolvedValue({ hfTokenPresent: true, source: 'stored' }); + installModelAbuseGuard.mockResolvedValue({ ok: true, ready: true }); + cancelModelAbuseGuardInstall.mockResolvedValue({ cancelled: true }); +}); + +const renderPanel = async () => { + render(); + expect(await screen.findByRole('heading', { name: 'Model-abuse guard' })).toBeInTheDocument(); +}; + +describe('ModelAbuseGuardPanel', () => { + it('tracks each install stage separately from the chat catalog', async () => { + await renderPanel(); + + expect(screen.getByText('Recommended safety layer · managed classifier')).toBeInTheDocument(); + expect(screen.getByRole('list', { name: 'Abuse guard setup stages' })).toBeInTheDocument(); + expect(screen.getByTestId('abuse-guard-stage-huggingface-token')).toHaveAttribute('data-ready', 'true'); + expect(screen.getByTestId('abuse-guard-stage-python')).toHaveAttribute('data-ready', 'true'); + expect(screen.getByTestId('abuse-guard-stage-venv')).toHaveAttribute('data-ready', 'false'); + expect(screen.getByTestId('abuse-guard-stage-packages')).toHaveAttribute('data-ready', 'false'); + expect(screen.getByTestId('abuse-guard-stage-model')).toHaveAttribute('data-ready', 'false'); + expect(screen.queryByText('Recommended for Security Scan')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Install model-abuse guard' })).toBeInTheDocument(); + }); + + it('marks the live install stage from security-guard progress events', async () => { + let deferred; + installModelAbuseGuard.mockImplementation(() => new Promise((resolve) => { deferred = resolve; })); + await renderPanel(); + + fireEvent.click(screen.getByRole('button', { name: 'Install model-abuse guard' })); + await waitFor(() => expect(installModelAbuseGuard).toHaveBeenCalled()); + await act(async () => {}); + + const handler = socket.on.mock.calls.find((call) => call[0] === 'localLlm:progress')?.[1]; + expect(handler).toEqual(expect.any(Function)); + act(() => { + handler({ scope: 'security-guard', event: 'stage', stage: 'venv', message: 'Preparing the dedicated Prompt Guard runtime…' }); + }); + expect(screen.getByTestId('abuse-guard-stage-venv').textContent).toMatch(/Installing/); + expect(screen.getByText('Preparing the dedicated Prompt Guard runtime…')).toBeInTheDocument(); + + await act(async () => { deferred({ ok: true, ready: true }); }); + }); +}); diff --git a/client/src/components/settings/LocalLlmTab.jsx b/client/src/components/settings/LocalLlmTab.jsx index 2f8508351a..aaab770311 100644 --- a/client/src/components/settings/LocalLlmTab.jsx +++ b/client/src/components/settings/LocalLlmTab.jsx @@ -9,7 +9,6 @@ import { localLlmTargetKey } from '../../lib/localLlmTargetKey'; import { useConfirmDelete } from '../../hooks/useConfirmDelete'; import { getLocalLlmStatus, getLocalLlmCatalog, getLocalLlmHuggingFaceSearch, installLocalLlmModel, - getModelAbuseGuardStatus, installModelAbuseGuard, cancelModelAbuseGuardInstall, deleteLocalLlmModel, migrateLocalLlmBackend, installLocalLlmBackend, upgradeLocalLlmBackend, controlOllamaService, installAudioModel, patchSettingsSlice, getLlamaServerStatus, getLlamaServerUpdateStatus, startLlamaServer, stopLlamaServer, installLlamaServer, upgradeLlamaServer, downloadSpecDecodeModel, cancelSpecDecodeModelDownload, controlLmStudioService, getMtplxServerStatus, startMtplxServer, stopMtplxServer, installMtplx, @@ -23,9 +22,8 @@ import RuntimeServersCard from './RuntimeServersCard.jsx'; import MtplxServerCard from './MtplxServerCard.jsx'; import LocalLlmBackendCard from './LocalLlmBackendCard.jsx'; import LocalLlmInstalledModels from './LocalLlmInstalledModels.jsx'; -import PromptGuardHfAccessNotice from '../imageGen/PromptGuardHfAccessNotice.jsx'; +import ModelAbuseGuardPanel from '../models/ModelAbuseGuardPanel.jsx'; import TabPills from '../ui/TabPills.jsx'; -import { useHfTokenStatus } from '../../hooks/useHfTokenStatus'; const BACKENDS = [ { id: 'ollama', label: 'Ollama', icon: Cpu }, @@ -61,9 +59,18 @@ const LLAMA_CACHE_TYPES = ['f16', 'q8_0', 'q4_0']; const btnClass = 'flex items-center gap-1.5 px-2 py-1 text-xs font-medium rounded transition-colors disabled:opacity-50'; -const LLM_VIEWS = [ +export const LLM_VIEWS = [ { id: 'runtimes', label: 'Runtimes', icon: Server }, { id: 'library', label: 'Model Library', icon: Download }, + { id: 'abuse', label: 'Abuse Guard', icon: ShieldCheck }, +]; + +// Palettable LLM drill-downs. Runtimes and Model Library stay focused views of +// `/models/llms` (the Models → LLMs landing). Abuse Guard is a managed +// classifier lifecycle of its own, so ⌘K and voice need a dedicated path. +// Scraped by server/lib/navManifest.test.js. +export const LLM_NAV_SUBROUTES = [ + { id: 'abuse' }, ]; const CATEGORY_LABELS = { @@ -167,10 +174,6 @@ export function LocalLlmTab({ view }) { const [catalog, setCatalog] = useState([]); const [catalogLoading, setCatalogLoading] = useState(false); const [catalogError, setCatalogError] = useState(''); - const [guardStatus, setGuardStatus] = useState(null); - const { present: guardHfTokenPresent, source: guardHfTokenSource, refresh: refreshGuardHfToken } = useHfTokenStatus({ - enabled: activeView === 'library', - }); // Total unified/system memory (GB) reported by the HF search, used to caption // the RAM-aware quant defaults. null until the first Hugging Face search. const [systemMemoryGb, setSystemMemoryGb] = useState(null); @@ -266,17 +269,12 @@ export function LocalLlmTab({ view }) { .catch(() => null) ), []); - const loadGuardStatus = useCallback(() => ( - getModelAbuseGuardStatus({ silent: true }) - .then((res) => { - if (res) setGuardStatus(res); - return res; - }) - .catch(() => null) - ), []); - const loadStatus = useCallback(() => { const requestId = ++statusRequestId.current; + if (activeView === 'abuse') { + setLoading(false); + return Promise.resolve(); + } setLoading(true); if (activeView === 'runtimes') { loadLlamaStatus(); @@ -331,7 +329,6 @@ export function LocalLlmTab({ view }) { }, []); useEffect(() => { loadStatus(); }, [loadStatus]); - useEffect(() => { loadGuardStatus(); }, [loadGuardStatus]); // The preset select mounts pre-selected, so the form has to be filled in the // moment the presets land — otherwise the recommended preset reads as chosen @@ -437,7 +434,7 @@ export function LocalLlmTab({ view }) { // per model, so answering them would reload the status AND re-query the // Hugging Face catalog once per measured model, all night. This tab owns // the unscoped install/migrate/upgrade frames only. - if (data?.scope === 'assessment' || data?.scope === 'assessment-sweep') return; + if (data?.scope === 'assessment' || data?.scope === 'assessment-sweep' || data?.scope === 'security-guard') return; clearTimeout(progressTimer.current); setProgressMsg(data.message || ''); if (data.event === 'complete') { @@ -563,14 +560,6 @@ export function LocalLlmTab({ view }) { (r) => r?.message || 'MTPLX stopped' ).then(loadMtplxStatus); - const installGuard = () => runAction( - 'security-guard-install', - () => installModelAbuseGuard(), - 'Model-abuse guard installed and ready', - ).then(loadGuardStatus); - const cancelGuard = () => cancelModelAbuseGuardInstall({ silent: true }) - .then(loadGuardStatus) - .catch(() => null); // Checkpoint management (search / download / remove), owned by the MTPLX card. // // `mtplxSearch` keeps a stable identity because the checkpoint panel keys its @@ -981,7 +970,9 @@ export function LocalLlmTab({ view }) {

{activeView === 'runtimes' ? 'Install, start, stop, and configure the local servers that run language models.' - : 'Find, install, compare, and remove the model weights available to Ollama and LM Studio.'} + : activeView === 'abuse' + ? 'Install and verify each stage of the pinned Prompt Guard classifier used to screen external content.' + : 'Find, install, compare, and remove the model weights available to Ollama and LM Studio.'}

@@ -1519,80 +1510,10 @@ export function LocalLlmTab({ view }) { )} + {activeView === 'abuse' && } + {activeView === 'library' && (
-
-
-
-
- {guardStatus?.ready === true ? ( - Ready - ) : guardStatus ? ( - Not installed - ) : ( - Checking status… - )} -
-

- Llama Prompt Guard 2 86M screens complete external issues, comments, and pull-request diffs before they reach a reasoning agent. It is a pinned local classifier with no chat, tools, MCP, or repository access; flagged or inconclusive content is withheld. -

- -
- Llama Prompt Guard 2 86M - · - 86M · offline · no tools - - Model card - -
-
- {guardStatus?.ready === true ? ( - Installed from the pinned model revision. - ) : actionInProgress === 'security-guard-install' ? ( - <> - Installing the dedicated guard… - - - ) : ( - - )} - {actionInProgress === 'security-guard-install' && progressMsg && ( - {progressMsg} - )} -
-
{/* Models — backend picker + catalog/install + installed list */}
diff --git a/client/src/components/settings/LocalLlmTab.test.jsx b/client/src/components/settings/LocalLlmTab.test.jsx index dd9918980c..1063a3a170 100644 --- a/client/src/components/settings/LocalLlmTab.test.jsx +++ b/client/src/components/settings/LocalLlmTab.test.jsx @@ -6,10 +6,6 @@ vi.mock('../../services/api', () => ({ getLocalLlmStatus: vi.fn(), getLocalLlmCatalog: vi.fn(), getLocalLlmHuggingFaceSearch: vi.fn(), - getModelAbuseGuardStatus: vi.fn(), - getHfTokenStatus: vi.fn(), - installModelAbuseGuard: vi.fn(), - cancelModelAbuseGuardInstall: vi.fn(), installLocalLlmModel: vi.fn(), deleteLocalLlmModel: vi.fn(), switchLocalLlmBackend: vi.fn(), @@ -44,15 +40,14 @@ vi.mock('../../services/socket', () => ({ vi.mock('../ui/Toast', () => ({ default: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }), })); +vi.mock('../models/ModelAbuseGuardPanel.jsx', () => ({ + default: () =>
abuse panel
, +})); import { deleteLocalLlmModel, getLocalLlmStatus, getLocalLlmCatalog, - getModelAbuseGuardStatus, - getHfTokenStatus, - installModelAbuseGuard, - cancelModelAbuseGuardInstall, installLocalLlmBackend, patchSettingsSlice, installLocalLlmModel, @@ -79,6 +74,8 @@ const renderTab = async (view = 'runtimes') => { await waitFor(() => expect(screen.getByRole('tabpanel')).toHaveAttribute('id', `llm-management-panel-${view}`)); if (view === 'library') { await waitFor(() => expect(screen.getByText(/Installed on (Ollama|LM Studio)/)).toBeTruthy()); + } else if (view === 'abuse') { + await waitFor(() => expect(screen.getByTestId('model-abuse-guard-card')).toBeInTheDocument()); } else { await waitFor(() => expect(screen.getByTitle(/PortOS routes local-LLM runs here by default/)).toBeInTheDocument()); } @@ -110,18 +107,6 @@ beforeEach(() => { lmstudio: { installed: false, available: false, modelCount: 0, models: [] }, }); getLocalLlmCatalog.mockResolvedValue({ models: [] }); - getModelAbuseGuardStatus.mockResolvedValue({ - id: 'llama-prompt-guard-2-86m', - name: 'Llama Prompt Guard 2 86M', - repository: 'meta-llama/Llama-Prompt-Guard-2-86M', - sourceUrl: 'https://huggingface.co/meta-llama/Llama-Prompt-Guard-2-86M', - ready: false, - modelCached: false, - runtimeReady: false, - }); - getHfTokenStatus.mockResolvedValue({ hfTokenPresent: true, source: 'stored' }); - installModelAbuseGuard.mockResolvedValue({ ok: true, ready: true }); - cancelModelAbuseGuardInstall.mockResolvedValue({ cancelled: true }); installLocalLlmBackend.mockResolvedValue({ success: true }); patchSettingsSlice.mockResolvedValue({}); deleteLocalLlmModel.mockResolvedValue({ success: true }); @@ -136,6 +121,15 @@ describe('LocalLlmTab information architecture', () => { expect(getLocalLlmCatalog).not.toHaveBeenCalled(); }); + it('gives the model-abuse guard its own panel without mounting the catalog', async () => { + await renderTab('abuse'); + + expect(screen.getByTestId('model-abuse-guard-card')).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Models' })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Local Runtime Servers' })).not.toBeInTheDocument(); + expect(getLocalLlmCatalog).not.toHaveBeenCalled(); + }); + it('gives model installation its own panel without mounting runtime management', async () => { const { getLlamaServerStatus, getMtplxServerStatus } = await import('../../services/api'); @@ -153,6 +147,15 @@ describe('LocalLlmTab information architecture', () => { fireEvent.click(screen.getByRole('tab', { name: 'Model Library' })); expect(screen.getByTestId('location')).toHaveTextContent('/models/llms/library'); + fireEvent.click(screen.getByRole('tab', { name: 'Abuse Guard' })); + expect(screen.getByTestId('location')).toHaveTextContent('/models/llms/abuse'); + }); + + it('keeps the model-abuse guard off the catalog panel', async () => { + await renderTab('library'); + + expect(screen.queryByRole('heading', { name: 'Model-abuse guard' })).not.toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Models' })).toBeInTheDocument(); }); }); @@ -489,21 +492,6 @@ describe('LocalLlmTab recommendations', () => { await waitFor(() => expect(screen.getByText('Qwen3.8 27B')).toBeTruthy()); }); - it('highlights the managed model-abuse guard separately from the chat catalog', async () => { - getLocalLlmCatalog.mockResolvedValue({ - models: [], - securityGuards: [{ id: 'llama-prompt-guard-2-86m' }], - }); - - await renderTab('library'); - - expect(await screen.findByRole('heading', { name: 'Model-abuse guard' })).toBeInTheDocument(); - expect(screen.getByText('Recommended safety layer · managed classifier')).toBeInTheDocument(); - expect(screen.getByText('Llama Prompt Guard 2 86M')).toBeInTheDocument(); - expect(screen.queryByText('Recommended for Security Scan')).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Install model-abuse guard' })).toBeInTheDocument(); - }); - it('offers redownload on an already-installed catalog card', async () => { installLocalLlmModel.mockResolvedValue({ success: true }); getLocalLlmCatalog.mockResolvedValue({ diff --git a/client/src/pages/Models.jsx b/client/src/pages/Models.jsx index 56b3db8a76..931a380f73 100644 --- a/client/src/pages/Models.jsx +++ b/client/src/pages/Models.jsx @@ -28,7 +28,7 @@ const MediaModels = lazyWithReload(() => import('./MediaModels')); * * - **3D** — image-to-3D runtime install/repair (TRELLIS.2, Pixal3D). * - **Embeddings** — the embedding model backing pgvector search. - * - **LLMs** — focused runtime-management and model-library sub-routes. + * - **LLMs** — focused runtime, model-library, and abuse-guard sub-routes. * - **LoRAs** — installed image/video adapters. * - **Media** — image/video checkpoints and the Hugging Face cache. * - **Performance** — measured assessments and launch-tuning comparison. @@ -83,7 +83,7 @@ export default function Models() { // A record id in the URL selects the tab's drill-down, when it has one. Tabs // without a detail component receive it as a focused sub-view id (LLMs uses - // `runtimes` and `library`); tabs that do not recognize it render their index. + // `runtimes`, `library`, and `abuse`); tabs that do not recognize it render their index. const DetailContent = recordId && Object.hasOwn(TAB_DETAIL, activeTab) ? TAB_DETAIL[activeTab] : null; const TabContent = TAB_CONTENT[activeTab]; diff --git a/client/src/pages/Models.test.jsx b/client/src/pages/Models.test.jsx index 2eb2b00981..283f6eea36 100644 --- a/client/src/pages/Models.test.jsx +++ b/client/src/pages/Models.test.jsx @@ -130,9 +130,9 @@ describe('Models', () => { }); describe('Models — tab drill-downs', () => { - it('passes an LLM sub-route through to the focused LLM view', async () => { - renderAt('/models/llms/library'); - expect(await screen.findByTestId('llms-view')).toHaveAttribute('data-view', 'library'); + it.each(['library', 'abuse'])('passes the LLM %s sub-route through to the focused LLM view', async (view) => { + renderAt(`/models/llms/${view}`); + expect(await screen.findByTestId('llms-view')).toHaveAttribute('data-view', view); expect(screen.getByRole('tab', { name: 'LLMs' })).toHaveAttribute('aria-selected', 'true'); }); diff --git a/server/lib/README.md b/server/lib/README.md index 3cfd0d7ff7..f54d576fa3 100644 --- a/server/lib/README.md +++ b/server/lib/README.md @@ -366,7 +366,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub | `huggingfaceLora.js` | HuggingFace LoRA import helpers: parse HF ref → `{repo,revision,file}`, fetch `/api/models` metadata, select an exact or family-matching `.safetensors`, detect the image or video LoRA family (`flux2` / `ltx-video` / …), build the sidecar + `resolve` download URL. The HF analogue of `civitai.js`. Pure. | | `huggingfaceModel.js` | HuggingFace base-model (image/video) classifier for the self-service "add a model" flow (#2124): inspect repo siblings + card → decide the loadable runtime/runner, STRICTLY refuse GGUF-only / wan / hunyuan / unclassifiable repos (so a bad add can't wedge the picker), build the `media-models.json` entry (`source:'user'`), + a `searchHuggingfaceModels` Hub-search helper. Pure. | | `localLlmCatalog.js` | Curated cross-backend (Ollama↔LM Studio) local-LLM catalog + install-id mapping for the migrate flow. Pure. | -| `modelAbuseGuard.js` | Pinned model-abuse boundary contract: Prompt Guard metadata, deterministic abuse signals, classifier-envelope validation, chunk/timeout limits, and fixed install requirements. Pure. | +| `modelAbuseGuard.js` | Pinned model-abuse boundary contract: Prompt Guard metadata, deterministic abuse signals, classifier-envelope validation, chunk/timeout limits, fixed install requirements, and `MODEL_ABUSE_GUARD_STAGES` / `modelAbuseGuardStageReadiness` for the operator-facing install checklist. Pure. | | `localLlmDisk.js` | Pure on-disk reasoning for the migrate "copy GGUF locally instead of re-downloading" fast-path (Ollama manifest/blob parsing, LM Studio path layout, MLX/projector/shard detection) plus the Hugging Face registry addressing used to finish an abandoned Ollama pull. | | `specDecodePresets.js` | Curated llama-server speculative-decoding presets (target + drafter GGUF paths, `--spec-type`, and the Hugging Face repo/quant each file comes from) plus `findSpecDecodePreset` / `specDecodeSource` / `hfSearchUrl`. Also owns the `--spec-type` vocabulary: `SPEC_TYPE_SUGGESTIONS` (published to the launcher card), `parseSpecTypes` (the flag is a comma-separated list) and `isDraftSpecType` (the `draft-` prefix is what needs a drafter GGUF — every `ngram-*` type runs without one). Server-owned so the launcher card can offer a Download button per file. Pure. | | `llamaCppInstall.js` | Where a llama.cpp install comes from on this host, for `services/llamaServerManager.js`. `llamaCppInstallPlan(platform)` → the frozen descriptor for that platform's package manager — Homebrew on macOS/Linux (`brew install llama.cpp`), winget on Windows (`winget install ggml.llamacpp`) — carrying `manager`, `managerLabel`, `packageId`, `installCommand`, the install/upgrade (and, for winget, list/upgrade-check) argv, and the three refusal strings a caller would otherwise hardcode: `missingManagerError`, `notInstalledError`, `pathRepairHint`. The LLMs page renders `installCommand` out of the llama-server status payload, which is what stopped a Windows install prompt from telling the user to run Homebrew. Also the winget-side readers Homebrew needs no equivalent for: `parseWingetPackageFields(stdout, id)` (winget has NO machine-readable output mode and LOCALIZED table headers, so fields are read positionally from the tokens after the id token — `Version [Available] [Source]`; `null` = not installed), `wingetLinkDirs(env)` (the portable-shim directories winget adds to the USER PATH, which an already-running server has not inherited) and `isWingetManagedPath` (counterpart of the manager's `isHomebrewLlamaServer`: a source build earlier on PATH must not be offered a `winget upgrade`). Every WinGet path is reasoned about with `path.win32` regardless of host, so both branches are coverable from a macOS/Linux checkout. Pure. | diff --git a/server/lib/modelAbuseGuard.js b/server/lib/modelAbuseGuard.js index fbe4bce3a9..2fd09e2aa7 100644 --- a/server/lib/modelAbuseGuard.js +++ b/server/lib/modelAbuseGuard.js @@ -67,6 +67,68 @@ export const MODEL_ABUSE_GUARD_PYTHON_IMPORTS = Object.freeze([ 'huggingface_hub' ]); +// Operator-facing install stages, in the order `installModelAbuseGuard` runs +// them. Status maps host facts onto this list; the UI must not invent a +// parallel checklist. Token presence is a boolean on the stage — never a token +// value, path, or exception string. +export const MODEL_ABUSE_GUARD_STAGES = Object.freeze([ + { + id: 'huggingface-token', + label: 'Hugging Face access token', + description: 'A read token plus gated-model approval on the Prompt Guard model card.', + }, + { + id: 'python', + label: 'Host Python', + description: 'A Python interpreter PortOS can use as the base for the dedicated runtime.', + }, + { + id: 'venv', + label: 'Dedicated Prompt Guard runtime', + description: 'A private virtualenv that never shares packages with image or video generation.', + }, + { + id: 'packages', + label: 'Classifier packages', + description: 'Pinned torch, transformers, safetensors, and huggingface_hub imports.', + }, + { + id: 'model', + label: 'Pinned model snapshot', + description: 'The five required Prompt Guard files from the pinned revision.', + }, +]); + +/** + * Map host facts onto the fixed install-stage list. + * + * `ready` on the envelope is the scan-time gate (cached weights + importable + * runtime). Token/Python/venv are prerequisites the installer still has to + * clear; they do not by themselves make the classifier usable. + */ +export function modelAbuseGuardStageReadiness({ + huggingfaceTokenPresent = false, + pythonAvailable = false, + venvReady = false, + runtimeReady = false, + modelCached = false, +} = {}) { + const readyById = { + 'huggingface-token': huggingfaceTokenPresent === true, + python: pythonAvailable === true, + venv: venvReady === true, + packages: runtimeReady === true, + model: modelCached === true, + }; + return { + stages: MODEL_ABUSE_GUARD_STAGES.map((stage) => ({ + ...stage, + ready: readyById[stage.id] === true, + })), + ready: runtimeReady === true && modelCached === true, + }; +} + // Read the complete supplied item up to this bound. Never truncate and then // treat the prefix as a trustworthy verdict. export const MODEL_ABUSE_GUARD_MAX_INPUT_CHARS = 2_000_000; diff --git a/server/lib/modelAbuseGuard.test.js b/server/lib/modelAbuseGuard.test.js index a12b2dbab5..ffc7d46e04 100644 --- a/server/lib/modelAbuseGuard.test.js +++ b/server/lib/modelAbuseGuard.test.js @@ -1,14 +1,47 @@ import { describe, expect, it } from 'vitest'; import { MODEL_ABUSE_GUARD_MAX_CHUNKS, + MODEL_ABUSE_GUARD_STAGES, detectDeterministicModelAbuseSignals, formatPublicReviewInputPrompt, hasToolFreeTextCapability, modelAbuseContentFingerprint, + modelAbuseGuardStageReadiness, normalizeModelAbuseGuardResult, } from './modelAbuseGuard.js'; describe('model-abuse guard contract', () => { + it('lists every install stage in installer order without implying the classifier is ready', () => { + expect(MODEL_ABUSE_GUARD_STAGES.map((stage) => stage.id)).toEqual([ + 'huggingface-token', + 'python', + 'venv', + 'packages', + 'model', + ]); + expect(modelAbuseGuardStageReadiness({ + huggingfaceTokenPresent: true, + pythonAvailable: true, + venvReady: true, + })).toMatchObject({ + ready: false, + stages: [ + expect.objectContaining({ id: 'huggingface-token', ready: true }), + expect.objectContaining({ id: 'python', ready: true }), + expect.objectContaining({ id: 'venv', ready: true }), + expect.objectContaining({ id: 'packages', ready: false }), + expect.objectContaining({ id: 'model', ready: false }), + ], + }); + expect(modelAbuseGuardStageReadiness({ + huggingfaceTokenPresent: true, + pythonAvailable: true, + venvReady: true, + runtimeReady: true, + modelCached: true, + }).ready).toBe(true); + }); + it('requires an explicit text capability and rejects native tools', () => { expect(hasToolFreeTextCapability(['completion'])).toBe(true); expect(hasToolFreeTextCapability(['chat'])).toBe(true); diff --git a/server/lib/navManifest.js b/server/lib/navManifest.js index 64e8032a76..07f0dccb00 100644 --- a/server/lib/navManifest.js +++ b/server/lib/navManifest.js @@ -279,6 +279,7 @@ const RAW_NAV_COMMANDS = [ { id: 'nav.models.3d', path: '/models/3d', label: '3D', section: 'Models', aliases: ['3d-runtimes', 'image-to-3d-runtimes', 'trellis-install', 'pixal3d-install'], keywords: ['trellis', 'pixal3d', 'install', 'repair', 'runtime', 'mesh', 'image to 3d', 'on-device'] }, { id: 'nav.settings.embeddings', path: '/models/embeddings', label: 'Embeddings', section: 'Models', previousPaths: ['/settings/embeddings'], aliases: ['settings-embeddings', 'embeddings', 'embedding'], keywords: ['vector', 'pgvector', 'semantic search', 'nomic', 'ollama', 'lm studio'] }, { id: 'nav.settings.local-llm', path: '/models/llms', label: 'LLMs', section: 'Models', previousPaths: ['/settings/local-llm'], aliases: ['local-llm', 'local-llms', 'llms', 'models-llms', 'ollama', 'lm-studio', 'lmstudio'], keywords: ['ollama', 'lm studio', 'local model', 'local llm', 'gguf', 'pull model', 'install model', 'migrate', 'switch backend', 'llama.cpp'] }, + { id: 'nav.models.llms.abuse', path: '/models/llms/abuse', label: 'Abuse Guard', section: 'Models', aliases: ['abuse-guard', 'model-abuse', 'model-abuse-guard', 'prompt-guard', 'prompt guard'], keywords: ['classifier', 'prompt injection', 'security scan', 'llama prompt guard', 'install guard'] }, { id: 'nav.media.loras', path: '/models/loras', label: 'LoRAs', section: 'Models', previousPaths: ['/media/loras'], aliases: ['loras', 'lora', 'lora-manager', 'civitai'], keywords: ['lora', 'civitai', 'fine-tune', 'style adapter', 'realstagram', 'photoreal', 'flux lora'] }, { id: 'nav.media.training', path: '/models/training', label: 'Training', section: 'Models', previousPaths: ['/media/training', '/media/training/:datasetId'], aliases: ['training', 'lora-training', 'train-lora', 'datasets', 'character-lora'], keywords: ['fine-tune', 'dataset', 'caption', 'dreambooth', 'character consistency', 'train', 'flux lora'] }, { id: 'nav.media.models', path: '/models/media', label: 'Media', section: 'Models', previousPaths: ['/media/models', '/media-models'], aliases: ['media-models', 'image-models', 'video-models', 'huggingface'], keywords: ['hf cache', 'model storage', 'disk', 'add model', 'install model', 'custom model'] }, diff --git a/server/lib/navManifest.test.js b/server/lib/navManifest.test.js index e48b5aae27..ef47782319 100644 --- a/server/lib/navManifest.test.js +++ b/server/lib/navManifest.test.js @@ -36,7 +36,10 @@ const TABBED_PAGES = [ { prefix: '/messages', file: 'client/src/pages/Messages.jsx', kind: 'ids', constName: 'TABS' }, { prefix: '/wiki', file: 'client/src/pages/Wiki.jsx', kind: 'ids', constName: 'TABS' }, { prefix: '/settings', file: 'client/src/components/settings/SettingsTabsHeader.jsx', kind: 'links', constName: 'TABS' }, - { prefix: '/models', file: 'client/src/components/models/ModelsTabsHeader.jsx', kind: 'links', constName: 'TABS' }, + { prefix: '/models', file: 'client/src/components/models/ModelsTabsHeader.jsx', kind: 'links', constName: 'TABS', + nestedIdSources: [ + { parent: 'llms', file: 'client/src/components/settings/LocalLlmTab.jsx', constName: 'LLM_NAV_SUBROUTES' }, + ] }, { prefix: '/media', file: 'client/src/pages/MediaGen.jsx', kind: 'ids', constName: 'TABS', allowBasePrefix: true }, { prefix: '/music', file: 'client/src/pages/Music.jsx', kind: 'ids', constName: 'TABS', allowBasePrefix: true }, { prefix: '/sharing', file: 'client/src/pages/Sharing.jsx', kind: 'links', constName: 'SECTIONS' }, @@ -111,12 +114,15 @@ function extractTabPaths(filePath, { kind, constName, switchVar, prefix, nestedI const block = extractConstArrayBlock(src, constName); if (kind === 'ids') { const ids = [...block.matchAll(/id:\s*['"]([^'"]+)['"]/g)].map((m) => `${prefix}/${m[1]}`); - return allowBasePrefix ? [prefix, ...ids] : ids; + return [...(allowBasePrefix ? [prefix, ...ids] : ids), ...nested]; } // kind 'links': keep only entries that point at this page, dropping cross-links. - return [...block.matchAll(/(?:to|path):\s*['"]([^'"]+)['"]/g)] - .map((m) => m[1]) - .filter((p) => p === prefix || p.startsWith(`${prefix}/`)); + return [ + ...[...block.matchAll(/(?:to|path):\s*['"]([^'"]+)['"]/g)] + .map((m) => m[1]) + .filter((p) => p === prefix || p.startsWith(`${prefix}/`)), + ...nested, + ]; } describe('navManifest — shape invariants', () => { @@ -244,6 +250,8 @@ describe('resolveNavCommand — fuzzy matching', () => { expect(resolveNavCommand('loras')?.path).toBe('/models/loras'); expect(resolveNavCommand('lora training')?.path).toBe('/models/training'); expect(resolveNavCommand('embeddings')?.path).toBe('/models/embeddings'); + expect(resolveNavCommand('prompt guard')?.path).toBe('/models/llms/abuse'); + expect(resolveNavCommand('abuse-guard')?.path).toBe('/models/llms/abuse'); }); it('resolves Universe Builder to the /universes index path', () => { diff --git a/server/routes/localLlm.js b/server/routes/localLlm.js index 2c05f189d3..06aa3c8d99 100644 --- a/server/routes/localLlm.js +++ b/server/routes/localLlm.js @@ -94,7 +94,7 @@ const router = Router() const emitter = (req) => { const io = req.app.get('io') - return (event, message) => io?.emit('localLlm:progress', { event, message }) + return (event, message, extra) => io?.emit('localLlm:progress', { event, message, ...extra }) } // GET /api/local-llm/status — both backends + active marker @@ -191,7 +191,7 @@ router.get('/security-guard/status', asyncHandler(async (_req, res) => { router.post('/security-guard/install', asyncHandler(async (req, res) => { const emit = emitter(req) const result = await installModelAbuseGuard({ - onEvent: ({ event, message }) => emit(event, message), + onEvent: ({ event, message, stage }) => emit(event, message, { scope: 'security-guard', stage }), }) if (!result?.ok) { const code = result?.code || 'security-guard-install-failed' @@ -200,7 +200,7 @@ router.post('/security-guard/install', asyncHandler(async (req, res) => { : code === 'security-guard-huggingface-access-required' ? 'Hugging Face has not granted Prompt Guard access yet. Submit the usage request on its model card, then retry.' : code - emit('error', message) + emit('error', message, { scope: 'security-guard' }) throw new ServerError(message, { status: 502, code }) } res.json(result) diff --git a/server/services/modelAbuseGuard.js b/server/services/modelAbuseGuard.js index 7ede94be9c..05581c1c3f 100644 --- a/server/services/modelAbuseGuard.js +++ b/server/services/modelAbuseGuard.js @@ -32,6 +32,7 @@ import { MODEL_ABUSE_GUARD_TIMEOUT_MS, detectDeterministicModelAbuseSignals, hasToolFreeTextCapability, + modelAbuseGuardStageReadiness, normalizeModelAbuseGuardResult, } from '../lib/modelAbuseGuard.js'; import { findCachedRepoFiles } from '../lib/hfCache.js'; @@ -216,9 +217,13 @@ export function buildModelAbuseGuardEnv(source = process.env) { }; } -const emitInstall = (onEvent, event, message) => { +const emitInstall = (onEvent, event, message, stage) => { if (typeof onEvent !== 'function') return; - onEvent({ event, message: String(message || '').slice(0, MAX_INSTALL_EVENT_CHARS) }); + onEvent({ + event, + message: String(message || '').slice(0, MAX_INSTALL_EVENT_CHARS), + ...(stage ? { stage } : {}), + }); }; function availableGuardPython() { @@ -256,19 +261,32 @@ async function isRuntimeReady(pythonPath) { * are intentionally omitted from the API contract. */ export async function getModelAbuseGuardStatus() { - const [files, pythonPath] = await Promise.all([ + const [files, pythonPath, huggingfaceTokenPresent] = await Promise.all([ findCachedRepoFiles(MODEL_ABUSE_GUARD.repository, MODEL_ABUSE_GUARD_REQUIRED_FILES, { revision: MODEL_ABUSE_GUARD.revision, }), Promise.resolve(availableGuardPython()), + getHfToken().then((token) => Boolean(token)).catch(() => false), ]); const modelCached = Array.isArray(files); + const venvReady = Boolean(pythonPath); + const pythonAvailable = Boolean(detectVenvBasePythonSync()); const runtimeReady = await isRuntimeReady(pythonPath); + const { stages, ready } = modelAbuseGuardStageReadiness({ + huggingfaceTokenPresent, + pythonAvailable, + venvReady, + runtimeReady, + modelCached, + }); return { ...MODEL_ABUSE_GUARD, modelCached, runtimeReady, - ready: modelCached && runtimeReady, + pythonAvailable, + venvReady, + stages, + ready, }; } @@ -288,31 +306,31 @@ export function installModelAbuseGuard({ onEvent } = {}) { const basePython = detectVenvBasePythonSync(); if (!basePython) return failure('security-guard-python-unavailable'); await ensureDir(dirname(GUARD_VENV_DIR)); - emitInstall(onEvent, 'stage', 'Preparing the dedicated Prompt Guard runtime…'); + emitInstall(onEvent, 'stage', 'Preparing the dedicated Prompt Guard runtime…', 'venv'); const pythonPath = await createVenv(basePython, GUARD_VENV_DIR); cachedRuntime = null; - emitInstall(onEvent, 'stage', 'Installing the fixed classifier runtime packages…'); + emitInstall(onEvent, 'stage', 'Installing the fixed classifier runtime packages…', 'packages'); const packageRun = installPackages(pythonPath, [...MODEL_ABUSE_GUARD_PYTHON_IMPORTS], ({ type, message }) => { - if (type === 'complete') emitInstall(onEvent, 'stage', 'Classifier runtime packages are ready.'); - else if (type === 'error') emitInstall(onEvent, 'error', 'Classifier runtime package installation failed.'); - else if (message && /install|uninstall/i.test(message)) emitInstall(onEvent, 'stage', 'Installing classifier runtime packages…'); + if (type === 'complete') emitInstall(onEvent, 'stage', 'Classifier runtime packages are ready.', 'packages'); + else if (type === 'error') emitInstall(onEvent, 'error', 'Classifier runtime package installation failed.', 'packages'); + else if (message && /install|uninstall/i.test(message)) emitInstall(onEvent, 'stage', 'Installing classifier runtime packages…', 'packages'); }); installKill = packageRun.kill; const packageResult = await packageRun.promise; installKill = null; if (!packageResult?.ok) return failure('security-guard-runtime-install-failed'); - emitInstall(onEvent, 'stage', 'Downloading the pinned Prompt Guard model snapshot…'); + emitInstall(onEvent, 'stage', 'Downloading the pinned Prompt Guard model snapshot…', 'model'); const download = downloadHfRepo({ repo: MODEL_ABUSE_GUARD.repository, revision: MODEL_ABUSE_GUARD.revision, only: [...MODEL_ABUSE_GUARD_REQUIRED_FILES], pythonPath, onEvent: (event) => { - if (event?.type === 'error') emitInstall(onEvent, 'error', 'Prompt Guard model download failed.'); - else if (event?.type === 'progress') emitInstall(onEvent, 'progress', event.stage || 'Downloading Prompt Guard…'); - else if (event?.type === 'complete') emitInstall(onEvent, 'stage', 'Pinned Prompt Guard model snapshot downloaded.'); + if (event?.type === 'error') emitInstall(onEvent, 'error', 'Prompt Guard model download failed.', 'model'); + else if (event?.type === 'progress') emitInstall(onEvent, 'progress', event.stage || 'Downloading Prompt Guard…', 'model'); + else if (event?.type === 'complete') emitInstall(onEvent, 'stage', 'Pinned Prompt Guard model snapshot downloaded.', 'model'); }, }); installKill = download.kill;