From 49bea74df18a5e4143a3faaa318d6c3abe1cbae5 Mon Sep 17 00:00:00 2001 From: "CONNSKILL GmbH & Co. KG" Date: Wed, 8 Jul 2026 12:05:40 +0200 Subject: [PATCH 01/10] Add model overrides and Mittwald route --- .env.example | 5 + clients/cli/src/commands/model.ts | 7 +- clients/cli/src/commands/modelOverride.ts | 2 +- clients/cli/src/hooks/useAgent.ts | 34 +++--- .../migration.sql | 1 + clients/web/prisma/schema.prisma | 1 + clients/web/server/terminal-server.ts | 2 + .../(dashboard)/engagements/[id]/layout.tsx | 5 +- .../app/(dashboard)/engagements/new/page.tsx | 115 +++++++++++++++++- .../web/src/app/api/engagements/[id]/route.ts | 13 +- clients/web/src/app/api/engagements/route.ts | 12 ++ .../src/components/terminal/web-terminal.tsx | 6 + config/litellm_dynamic_config.py | 17 +++ docker-compose.lan.yml | 13 ++ 14 files changed, 208 insertions(+), 25 deletions(-) create mode 100644 clients/web/prisma/migrations/20260708120000_add_engagement_model_override/migration.sql create mode 100644 docker-compose.lan.yml diff --git a/.env.example b/.env.example index becf62f6a..581575a5a 100644 --- a/.env.example +++ b/.env.example @@ -105,6 +105,11 @@ CLOUDFLARE_AI_GATEWAY_API_KEY=your-cloudflare-ai-gateway-key-here # CUSTOM_OPENAI_API_KEY=... # CUSTOM_OPENAI_MODEL=gpt-4o-mini +# --- Mittwald AI Hosting (OpenAI-compatible) --- +# MITTWALD_AI_BASE_URL=https://llm.aihosting.mittwald.de/v1 +# MITTWALD_AI_KEY=... +# MITTWALD_AI_MODEL=Mistral-Medium-3.5-128B + # --- Local LLM (Ollama) --- # Set both to run Decepticon against a local Ollama model. The # launcher's onboard wizard writes these when you pick "Local LLM". diff --git a/clients/cli/src/commands/model.ts b/clients/cli/src/commands/model.ts index 5f44e1562..8dd0c44fb 100644 --- a/clients/cli/src/commands/model.ts +++ b/clients/cli/src/commands/model.ts @@ -68,6 +68,7 @@ const SUPPORTED_MODELS: Record = { "openrouter/anthropic/claude-opus-4-7", "openrouter/anthropic/claude-sonnet-4-6", "openrouter/anthropic/claude-haiku-4-5", + "openrouter/moonshotai/kimi-k2", ], "NVIDIA NIM": [ "nvidia_nim/meta/llama-3.3-70b-instruct", @@ -115,6 +116,7 @@ const SUPPORTED_MODELS: Record = { "cohere_chat/command-r", ], "Moonshot Kimi K2": [ + "openrouter/moonshotai/kimi-k2", "moonshot/kimi-k2-instruct", "moonshot/moonshot-v1-128k", "moonshot/moonshot-v1-8k", @@ -173,9 +175,10 @@ const SUPPORTED_MODELS: Record = { "cfgateway/anthropic/claude-sonnet-4-6", "cfgateway/anthropic/claude-haiku-4-5", ], + "Mittwald": ["mittwald/Mistral-Medium-3.5-128B"], // Local - "Ollama (local)": ["ollama_chat/qwen3-coder:30b (or your OLLAMA_MODEL)"], - "Ollama Cloud": ["ollama_chat/"], + "Ollama (local)": ["ollama_chat/qwen2.5-coder:7b", "ollama_chat/qwen3-coder:30b"], + "Ollama Cloud": ["ollama_cloud/kimi-k2.6"], "LM Studio (local)": ["lm_studio/"], "Custom OpenAI Endpoint": ["custom/"], }; diff --git a/clients/cli/src/commands/modelOverride.ts b/clients/cli/src/commands/modelOverride.ts index 3319fa71d..8afec5389 100644 --- a/clients/cli/src/commands/modelOverride.ts +++ b/clients/cli/src/commands/modelOverride.ts @@ -10,7 +10,7 @@ * Empty string == no override. */ -let _override = ""; +let _override = (process.env.DECEPTICON_MODEL_OVERRIDE ?? "").trim(); export function setModelOverride(id: string): void { _override = id.trim(); diff --git a/clients/cli/src/hooks/useAgent.ts b/clients/cli/src/hooks/useAgent.ts index f2fd8d4fd..8f80b5baa 100644 --- a/clients/cli/src/hooks/useAgent.ts +++ b/clients/cli/src/hooks/useAgent.ts @@ -58,6 +58,21 @@ interface PendingTool { args: Record; } +function buildRunConfig(): { config?: { configurable: Record } } { + const configurable: Record = {}; + const slug = process.env.DECEPTICON_ENGAGEMENT; + if (slug) { + configurable.engagement_name = slug; + configurable.workspace_path = + process.env.DECEPTICON_WORKSPACE_PATH ?? "/workspace"; + } + const modelOverride = getModelOverride(); + if (modelOverride) { + configurable.model_override = modelOverride; + } + return Object.keys(configurable).length > 0 ? { config: { configurable } } : {}; +} + export interface StreamStats { startTime: number; totalTokens: number; @@ -765,28 +780,13 @@ export function useAgent({ messages: [{ role: "user", content: message }], }; - const configurable: Record = {}; - const slug = process.env.DECEPTICON_ENGAGEMENT; - if (slug) { - configurable.engagement_name = slug; - configurable.workspace_path = - process.env.DECEPTICON_WORKSPACE_PATH ?? "/workspace"; - } - const modelOverride = getModelOverride(); - if (modelOverride) { - configurable.model_override = modelOverride; - } - - const hasConfigurable = Object.keys(configurable).length > 0; - const streamConfig = hasConfigurable ? { configurable } : undefined; - try { const stream = client.runs.stream( threadIdRef.current!, getAssistantOverride() || assistantIdRef.current, { input, - ...(streamConfig ? { config: streamConfig } : {}), + ...buildRunConfig(), ...STREAM_OPTIONS, onDisconnect: "continue", signal: abortController.signal, @@ -872,6 +872,7 @@ export function useAgent({ getAssistantOverride() || assistantIdRef.current, { command: { resume: value }, + ...buildRunConfig(), ...STREAM_OPTIONS, onDisconnect: "continue", signal: abortController.signal, @@ -934,6 +935,7 @@ export function useAgent({ getAssistantOverride() || assistantIdRef.current, { command: { resume: value ?? true }, + ...buildRunConfig(), ...STREAM_OPTIONS, onDisconnect: "continue", signal: abortController.signal, diff --git a/clients/web/prisma/migrations/20260708120000_add_engagement_model_override/migration.sql b/clients/web/prisma/migrations/20260708120000_add_engagement_model_override/migration.sql new file mode 100644 index 000000000..df1832f28 --- /dev/null +++ b/clients/web/prisma/migrations/20260708120000_add_engagement_model_override/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "Engagement" ADD COLUMN "modelOverride" TEXT; diff --git a/clients/web/prisma/schema.prisma b/clients/web/prisma/schema.prisma index ea989d3e7..f00415cbf 100644 --- a/clients/web/prisma/schema.prisma +++ b/clients/web/prisma/schema.prisma @@ -37,6 +37,7 @@ model Engagement { // LangGraph thread tracking threadId String? + modelOverride String? workspacePath String? createdAt DateTime @default(now()) diff --git a/clients/web/server/terminal-server.ts b/clients/web/server/terminal-server.ts index fc27d7620..62271ff4d 100755 --- a/clients/web/server/terminal-server.ts +++ b/clients/web/server/terminal-server.ts @@ -142,6 +142,7 @@ wss.on("connection", async (ws: WebSocket, req) => { const engagementId = url.searchParams.get("engagementId") ?? ""; const engagementSlug = url.searchParams.get("engagementSlug") ?? ""; const agentId = url.searchParams.get("agentId") ?? "soundwave"; + const modelOverride = (url.searchParams.get("modelOverride") ?? "").trim(); if (!engagementSlug) { ws.close(1008, "Missing engagementSlug"); @@ -214,6 +215,7 @@ wss.on("connection", async (ws: WebSocket, req) => { DECEPTICON_API_URL: LANGGRAPH_API_URL, }; if (threadId) env.DECEPTICON_THREAD_ID = threadId; + if (modelOverride) env.DECEPTICON_MODEL_OVERRIDE = modelOverride; let term: pty.IPty; try { diff --git a/clients/web/src/app/(dashboard)/engagements/[id]/layout.tsx b/clients/web/src/app/(dashboard)/engagements/[id]/layout.tsx index ed1057df0..eabd5516a 100644 --- a/clients/web/src/app/(dashboard)/engagements/[id]/layout.tsx +++ b/clients/web/src/app/(dashboard)/engagements/[id]/layout.tsx @@ -25,7 +25,7 @@ export default function EngagementLayout({ const pathname = usePathname(); const engagementId = params.id as string; - const [engagement, setEngagement] = useState<{ name: string } | null>(null); + const [engagement, setEngagement] = useState<{ name: string; modelOverride?: string | null } | null>(null); const [agentId, setAgentId] = useState<"soundwave" | "decepticon" | null>(null); const [threadId, setThreadId] = useState(null); @@ -39,7 +39,7 @@ export default function EngagementLayout({ fetch(`/api/engagements/${engagementId}/plan-docs`), ]); if (!engRes.ok) return; - const eng = (await engRes.json()) as { name: string; threadId?: string | null }; + const eng = (await engRes.json()) as { name: string; threadId?: string | null; modelOverride?: string | null }; const planDocs = planRes.ok ? ((await planRes.json()) as Record) : {}; if (cancelled) return; setEngagement(eng); @@ -90,6 +90,7 @@ export default function EngagementLayout({ engagementId={engagementId} engagementSlug={engagement!.name} agentId={agentId!} + modelOverride={engagement.modelOverride || ""} threadId={threadId ?? undefined} className="h-full" onThreadId={setThreadId} diff --git a/clients/web/src/app/(dashboard)/engagements/new/page.tsx b/clients/web/src/app/(dashboard)/engagements/new/page.tsx index f60bff7d9..80da2f5fa 100644 --- a/clients/web/src/app/(dashboard)/engagements/new/page.tsx +++ b/clients/web/src/app/(dashboard)/engagements/new/page.tsx @@ -7,14 +7,38 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; -import { Globe, Server, Loader2 } from "lucide-react"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Bot, Globe, Server, Loader2 } from "lucide-react"; import { isValidEngagementSlug } from "@/lib/engagement-slug"; +const MODEL_CHOICES = [ + { value: "default", label: "Default chain" }, + { value: "auth/gpt-5.4-mini", label: "GPT OAuth Mini" }, + { value: "auth/gpt-5.4", label: "GPT OAuth" }, + { value: "openrouter/moonshotai/kimi-k2", label: "Kimi K2 via OpenRouter" }, + { value: "groq/llama-3.1-8b-instant", label: "Groq Free" }, + { value: "mittwald", label: "Mittwald" }, + { value: "ollama-local", label: "Ollama local" }, + { value: "custom", label: "Custom model ID" }, +] as const; + export default function NewEngagementPage() { const router = useRouter(); const [targetType, setTargetType] = useState("web_url"); const [name, setName] = useState(""); const [targetValue, setTargetValue] = useState(""); + const [modelChoice, setModelChoice] = useState("default"); + const [mittwaldModel, setMittwaldModel] = useState("Mistral-Medium-3.5-128B"); + const [ollamaModel, setOllamaModel] = useState("qwen2.5-coder:7b"); + const [customModel, setCustomModel] = useState(""); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -24,11 +48,30 @@ export default function NewEngagementPage() { ? "Name must be 3-64 chars, lowercase letters / digits with internal hyphens only" : null; + const modelOverride = + modelChoice === "default" + ? "" + : modelChoice === "ollama-local" + ? ollamaModel.trim() + ? `ollama_chat/${ollamaModel.trim().replace(/^ollama_chat\//, "")}` + : "" + : modelChoice === "mittwald" + ? mittwaldModel.trim() + ? `mittwald/${mittwaldModel.trim().replace(/^mittwald\//, "")}` + : "" + : modelChoice === "custom" + ? customModel.trim() + : modelChoice; + async function handleSubmit() { if (!nameValid || !targetValue.trim()) { setError("Please fill in all required fields"); return; } + if ((modelChoice === "mittwald" || modelChoice === "ollama-local" || modelChoice === "custom") && !modelOverride) { + setError("Please provide a model id"); + return; + } setSubmitting(true); setError(null); @@ -37,7 +80,7 @@ export default function NewEngagementPage() { const res = await fetch("/api/engagements", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name, targetType, targetValue }), + body: JSON.stringify({ name, targetType, targetValue, modelOverride: modelOverride || null }), }); if (!res.ok) { @@ -46,7 +89,8 @@ export default function NewEngagementPage() { } const engagement = await res.json(); - router.push(`/engagements/${engagement.id}/live?new=true`); + const params = new URLSearchParams({ new: "true" }); + router.push(`/engagements/${engagement.id}/live?${params.toString()}`); } catch (err: unknown) { setError(err instanceof Error ? err.message : "Unknown error"); } finally { @@ -95,6 +139,71 @@ export default function NewEngagementPage() { + + + + + Model + + + +
+ + +
+ + {modelChoice === "ollama-local" && ( +
+ + setOllamaModel(e.target.value)} + /> +
+ )} + + {modelChoice === "mittwald" && ( +
+ + setMittwaldModel(e.target.value)} + /> +
+ )} + + {modelChoice === "custom" && ( +
+ + setCustomModel(e.target.value)} + /> +
+ )} +
+
+ Target Configuration diff --git a/clients/web/src/app/api/engagements/[id]/route.ts b/clients/web/src/app/api/engagements/[id]/route.ts index 66d1a9c36..de55b644d 100644 --- a/clients/web/src/app/api/engagements/[id]/route.ts +++ b/clients/web/src/app/api/engagements/[id]/route.ts @@ -48,7 +48,7 @@ export async function PATCH( return NextResponse.json({ error: "Not found" }, { status: 404 }); } - const ALLOWED_FIELDS = ["name", "status", "targetType", "targetValue", "threadId"] as const; + const ALLOWED_FIELDS = ["name", "status", "targetType", "targetValue", "threadId", "modelOverride"] as const; const data: Record = {}; for (const field of ALLOWED_FIELDS) { if (field in body) data[field] = body[field]; @@ -87,6 +87,17 @@ export async function PATCH( ); } + if ("modelOverride" in data) { + if (data.modelOverride == null || data.modelOverride === "") { + data.modelOverride = null; + } else if (typeof data.modelOverride !== "string" || data.modelOverride.length > 200) { + return NextResponse.json( + { error: "Invalid modelOverride. Must be a model id up to 200 chars" }, + { status: 400 } + ); + } + } + const engagement = await prisma.engagement.update({ where: { id }, data, diff --git a/clients/web/src/app/api/engagements/route.ts b/clients/web/src/app/api/engagements/route.ts index cfb34e5cb..15f1f880f 100644 --- a/clients/web/src/app/api/engagements/route.ts +++ b/clients/web/src/app/api/engagements/route.ts @@ -69,6 +69,10 @@ export async function POST(req: NextRequest) { const body = await req.json(); const { name, targetType, targetValue } = body; + const modelOverride = + typeof body.modelOverride === "string" && body.modelOverride.trim() + ? body.modelOverride.trim() + : null; if (!name || !targetType || !targetValue) { return NextResponse.json( @@ -84,6 +88,13 @@ export async function POST(req: NextRequest) { ); } + if (modelOverride && modelOverride.length > 200) { + return NextResponse.json( + { error: "Invalid modelOverride. Must be a model id up to 200 chars" }, + { status: 400 } + ); + } + // Engagement name doubles as the workspace slug. Enforce the same regex // the launcher uses so a name created here works as a folder name and a // future "Resume " picker entry without further escaping. @@ -112,6 +123,7 @@ export async function POST(req: NextRequest) { name, targetType, targetValue, + modelOverride, userId, workspacePath: wsPath, }, diff --git a/clients/web/src/components/terminal/web-terminal.tsx b/clients/web/src/components/terminal/web-terminal.tsx index db22483d9..f30b8314e 100644 --- a/clients/web/src/components/terminal/web-terminal.tsx +++ b/clients/web/src/components/terminal/web-terminal.tsx @@ -43,6 +43,7 @@ interface WebTerminalProps { engagementId: string; engagementSlug: string; agentId?: string; + modelOverride?: string; threadId?: string; className?: string; onThreadId?: (threadId: string) => void; @@ -52,6 +53,7 @@ export function WebTerminal({ engagementId, engagementSlug, agentId = "soundwave", + modelOverride, threadId, className, onThreadId, @@ -65,6 +67,8 @@ export function WebTerminal({ engagementSlugRef.current = engagementSlug; const agentIdRef = useRef(agentId); agentIdRef.current = agentId; + const modelOverrideRef = useRef(modelOverride ?? ""); + modelOverrideRef.current = modelOverride ?? ""; const threadIdRef = useRef(threadId); threadIdRef.current = threadId; const onThreadIdRef = useRef(onThreadId); @@ -121,12 +125,14 @@ export function WebTerminal({ const eid = engagementIdRef.current; const slug = engagementSlugRef.current; const aid = agentIdRef.current; + const model = modelOverrideRef.current.trim(); const tid = threadIdRef.current; let wsUrl = `${TERMINAL_WS_URL}?engagementId=${encodeURIComponent(eid)}` + `&engagementSlug=${encodeURIComponent(slug)}` + `&agentId=${encodeURIComponent(aid)}`; + if (model) wsUrl += `&modelOverride=${encodeURIComponent(model)}`; if (tid) wsUrl += `&threadId=${encodeURIComponent(tid)}`; const ws = new WebSocket(wsUrl); diff --git a/config/litellm_dynamic_config.py b/config/litellm_dynamic_config.py index 1c7fa4c7a..c9226f0ec 100644 --- a/config/litellm_dynamic_config.py +++ b/config/litellm_dynamic_config.py @@ -181,6 +181,7 @@ "synthetic": ("https://api.synthetic.new/openai/v1", "SYNTHETIC_API_KEY"), "zenmux": ("https://zenmux.ai/api/v1", "ZENMUX_API_KEY"), "qianfan": ("https://qianfan.baidubce.com/v2", "QIANFAN_API_KEY"), + "mittwald": ("os.environ/MITTWALD_AI_BASE_URL", "MITTWALD_AI_KEY"), # Per-account base URL: the operator sets it to their Cloudflare AI # Gateway OpenAI-compat endpoint (``…/compat``). Resolved by LiteLLM at # request time, so an unset base only fails the call (with a clear 404), @@ -401,6 +402,18 @@ def _ollama_model_from_env(source: Mapping[str, str]) -> str | None: return f"ollama_chat/{model}" +def _mittwald_model_from_env(source: Mapping[str, str]) -> str | None: + """Derive ``mittwald/`` from Mittwald's OpenAI-compatible env.""" + base = _clean_model(source.get("MITTWALD_AI_BASE_URL")) + model = _clean_model(source.get("MITTWALD_AI_MODEL")) + if base is None or model is None: + return None + lower = model.lower() + if lower.startswith("mittwald/"): + return model + return f"mittwald/{model}" + + def collect_requested_models(env: Mapping[str, str] | None = None) -> set[str]: """Collect model IDs requested through DECEPTICON_MODEL* env vars. @@ -424,6 +437,10 @@ def collect_requested_models(env: Mapping[str, str] | None = None) -> set[str]: if ollama_model is not None: models.add(ollama_model) + mittwald_model = _mittwald_model_from_env(source) + if mittwald_model is not None: + models.add(mittwald_model) + return models diff --git a/docker-compose.lan.yml b/docker-compose.lan.yml new file mode 100644 index 000000000..138fe500e --- /dev/null +++ b/docker-compose.lan.yml @@ -0,0 +1,13 @@ +services: + langgraph: + ports: !override + - "0.0.0.0:${LANGGRAPH_PORT:-2024}:2024" + + web: + ports: !override + - "0.0.0.0:${WEB_PORT:-3000}:3000" + - "0.0.0.0:${TERMINAL_PORT:-3003}:3003" + environment: + NEXT_PUBLIC_LANGGRAPH_API_URL: "http://100.84.167.36:${LANGGRAPH_PORT:-2024}" + NEXT_PUBLIC_TERMINAL_WS_URL: "ws://100.84.167.36:${TERMINAL_PORT:-3003}" + TERMINAL_ALLOWED_ORIGINS: "http://100.84.167.36:${WEB_PORT:-3000},http://192.168.178.26:${WEB_PORT:-3000},http://localhost:${WEB_PORT:-3000},http://127.0.0.1:${WEB_PORT:-3000}" From 95c4cfc2c679111630fd0b8cadc53883fd6d9101 Mon Sep 17 00:00:00 2001 From: "CONNSKILL GmbH & Co. KG" Date: Wed, 8 Jul 2026 12:49:59 +0200 Subject: [PATCH 02/10] Add dynamic local model picker --- .../app/(dashboard)/engagements/new/page.tsx | 131 +++++++++-- clients/web/src/app/api/local-models/route.ts | 205 ++++++++++++++++++ docker-compose.yml | 1 + 3 files changed, 324 insertions(+), 13 deletions(-) create mode 100644 clients/web/src/app/api/local-models/route.ts diff --git a/clients/web/src/app/(dashboard)/engagements/new/page.tsx b/clients/web/src/app/(dashboard)/engagements/new/page.tsx index 80da2f5fa..f73eeda7e 100644 --- a/clients/web/src/app/(dashboard)/engagements/new/page.tsx +++ b/clients/web/src/app/(dashboard)/engagements/new/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -16,7 +16,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Bot, Globe, Server, Loader2 } from "lucide-react"; +import { Bot, Globe, Server, Loader2, RefreshCw } from "lucide-react"; import { isValidEngagementSlug } from "@/lib/engagement-slug"; const MODEL_CHOICES = [ @@ -30,6 +30,33 @@ const MODEL_CHOICES = [ { value: "custom", label: "Custom model ID" }, ] as const; +type LocalModel = { + id: string; + label: string; + provider: "ollama" | "llamacpp" | "lmstudio" | "custom"; +}; + +const LOCAL_PROVIDER_LABELS: Record = { + ollama: "Ollama", + llamacpp: "llama.cpp", + lmstudio: "LM Studio", + custom: "Custom OpenAI", +}; + +function toLocalModelOverride(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return ""; + if ( + trimmed.startsWith("ollama_chat/") || + trimmed.startsWith("llamacpp/") || + trimmed.startsWith("lm_studio/") || + trimmed.startsWith("custom/") + ) { + return trimmed; + } + return `ollama_chat/${trimmed.replace(/^ollama(?:_chat)?\//, "")}`; +} + export default function NewEngagementPage() { const router = useRouter(); const [targetType, setTargetType] = useState("web_url"); @@ -37,11 +64,49 @@ export default function NewEngagementPage() { const [targetValue, setTargetValue] = useState(""); const [modelChoice, setModelChoice] = useState("default"); const [mittwaldModel, setMittwaldModel] = useState("Mistral-Medium-3.5-128B"); - const [ollamaModel, setOllamaModel] = useState("qwen2.5-coder:7b"); + const [localModel, setLocalModel] = useState("ollama_chat/qwen2.5-coder:7b"); + const [localModels, setLocalModels] = useState([]); + const [localModelsLoading, setLocalModelsLoading] = useState(false); const [customModel, setCustomModel] = useState(""); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); + const loadLocalModels = useCallback(async () => { + setLocalModelsLoading(true); + try { + const res = await fetch("/api/local-models", { cache: "no-store" }); + if (!res.ok) throw new Error("Failed to load local models"); + const data = (await res.json()) as { models?: LocalModel[] }; + const models = data.models ?? []; + setLocalModels(models); + if (models.length > 0 && !models.some((model) => model.id === localModel)) { + setLocalModel(models[0].id); + } + } catch { + setLocalModels([]); + } finally { + setLocalModelsLoading(false); + } + }, [localModel]); + + useEffect(() => { + void loadLocalModels(); + }, [loadLocalModels]); + + const localModelsByProvider = useMemo( + () => + localModels.reduce( + (groups, model) => { + const group = groups[model.provider] ?? []; + group.push(model); + groups[model.provider] = group; + return groups; + }, + {} as Partial>, + ), + [localModels], + ); + const nameValid = isValidEngagementSlug(name); const nameError = name.length > 0 && !nameValid @@ -52,9 +117,7 @@ export default function NewEngagementPage() { modelChoice === "default" ? "" : modelChoice === "ollama-local" - ? ollamaModel.trim() - ? `ollama_chat/${ollamaModel.trim().replace(/^ollama_chat\//, "")}` - : "" + ? toLocalModelOverride(localModel) : modelChoice === "mittwald" ? mittwaldModel.trim() ? `mittwald/${mittwaldModel.trim().replace(/^mittwald\//, "")}` @@ -168,13 +231,55 @@ export default function NewEngagementPage() { {modelChoice === "ollama-local" && (
- - setOllamaModel(e.target.value)} - /> +
+ + +
+ {localModels.length > 0 ? ( + + ) : ( + setLocalModel(e.target.value)} + /> + )}
)} diff --git a/clients/web/src/app/api/local-models/route.ts b/clients/web/src/app/api/local-models/route.ts new file mode 100644 index 000000000..268b47c61 --- /dev/null +++ b/clients/web/src/app/api/local-models/route.ts @@ -0,0 +1,205 @@ +import { NextResponse } from "next/server"; + +type LocalModel = { + id: string; + label: string; + provider: "ollama" | "llamacpp" | "lmstudio" | "custom"; +}; + +type ProbeResult = { + provider: LocalModel["provider"]; + status: "ok" | "error" | "skipped"; + detail?: string; +}; + +const ROUTE_PREFIX: Record = { + ollama: "ollama_chat", + llamacpp: "llamacpp", + lmstudio: "lm_studio", + custom: "custom", +}; + +const LITELLM_URL = trimBaseUrl(process.env.LITELLM_URL) || "http://litellm:4000"; +const LITELLM_KEY = process.env.LITELLM_API_KEY ?? process.env.LITELLM_MASTER_KEY ?? ""; + +function trimBaseUrl(value: string | undefined): string { + return (value ?? "").trim().replace(/\/+$/, ""); +} + +function ollamaNativeBase(value: string | undefined): string { + const base = trimBaseUrl(value); + return base.replace(/\/v1$/i, ""); +} + +function uniqueModels(models: LocalModel[]): LocalModel[] { + const seen = new Set(); + return models.filter((model) => { + if (seen.has(model.id)) return false; + seen.add(model.id); + return true; + }); +} + +async function fetchJson(url: string, headers?: HeadersInit): Promise { + const res = await fetch(url, { + headers, + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); +} + +async function registeredLiteLLMModels(): Promise> { + if (!LITELLM_KEY) return new Set(); + try { + const data = (await fetchJson(`${LITELLM_URL}/v1/models`, { + Authorization: `Bearer ${LITELLM_KEY}`, + })) as { data?: Array<{ id?: string }> }; + return new Set(data.data?.map((model) => model.id).filter((id): id is string => Boolean(id)) ?? []); + } catch { + return new Set(); + } +} + +function litellmParamsFor(model: LocalModel): Record { + if (model.provider === "ollama") { + const base = ollamaNativeBase(process.env.OLLAMA_API_BASE) || "http://host.docker.internal:11434"; + return { + model: model.id, + api_base: base, + }; + } + if (model.provider === "llamacpp") { + return { + model: `openai/${model.id.split("/", 2)[1]}`, + api_base: trimBaseUrl(process.env.LLAMACPP_API_BASE), + api_key: process.env.LLAMACPP_API_KEY ? "os.environ/LLAMACPP_API_KEY" : "llama-cpp", + }; + } + if (model.provider === "lmstudio") { + return { + model: `openai/${model.id.split("/", 2)[1]}`, + api_base: trimBaseUrl(process.env.LMSTUDIO_API_BASE), + api_key: process.env.LMSTUDIO_API_KEY ? "os.environ/LMSTUDIO_API_KEY" : "lm-studio", + }; + } + return { + model: `openai/${model.id.split("/", 2)[1]}`, + api_base: trimBaseUrl(process.env.CUSTOM_OPENAI_API_BASE), + api_key: "os.environ/CUSTOM_OPENAI_API_KEY", + }; +} + +async function ensureLiteLLMModels(models: LocalModel[]): Promise { + if (!LITELLM_KEY) return { provider: "ollama", status: "skipped", detail: "LiteLLM key not configured" }; + const registered = await registeredLiteLLMModels(); + const missing = models.filter((model) => !registered.has(model.id)); + if (missing.length === 0) return { provider: "ollama", status: "ok", detail: "all local models registered" }; + + let added = 0; + for (const model of missing) { + try { + const res = await fetch(`${LITELLM_URL}/model/new`, { + method: "POST", + headers: { + Authorization: `Bearer ${LITELLM_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model_name: model.id, + litellm_params: litellmParamsFor(model), + model_info: { source: model.provider, display_name: model.label }, + }), + signal: AbortSignal.timeout(5000), + }); + if (res.ok) added += 1; + } catch { + // Keep discovery usable even if a route could not be registered. + } + } + return { + provider: "ollama", + status: added === missing.length ? "ok" : "error", + detail: `registered ${added}/${missing.length} missing local models`, + }; +} + +async function listOllama(): Promise<{ models: LocalModel[]; result: ProbeResult }> { + const base = ollamaNativeBase(process.env.OLLAMA_API_BASE) || "http://host.docker.internal:11434"; + try { + const data = (await fetchJson(`${base}/api/tags`)) as { + models?: Array<{ name?: string; model?: string }>; + }; + const models = + data.models + ?.map((item) => (item.name ?? item.model ?? "").trim()) + .filter(Boolean) + .map((name) => ({ + id: `ollama_chat/${name}`, + label: name, + provider: "ollama" as const, + })) ?? []; + return { models, result: { provider: "ollama", status: "ok", detail: `${models.length} models` } }; + } catch (err) { + return { + models: [], + result: { + provider: "ollama", + status: "error", + detail: err instanceof Error ? err.message : "unreachable", + }, + }; + } +} + +async function listOpenAICompatible( + provider: "llamacpp" | "lmstudio" | "custom", + baseUrl: string | undefined, + apiKey: string | undefined, +): Promise<{ models: LocalModel[]; result: ProbeResult }> { + const base = trimBaseUrl(baseUrl); + if (!base) return { models: [], result: { provider, status: "skipped", detail: "base URL not configured" } }; + + const headers: HeadersInit = apiKey ? { Authorization: `Bearer ${apiKey}` } : {}; + try { + const data = (await fetchJson(`${base}/models`, headers)) as { + data?: Array<{ id?: string }>; + }; + const models = + data.data + ?.map((item) => (item.id ?? "").trim()) + .filter(Boolean) + .map((name) => ({ + id: `${ROUTE_PREFIX[provider]}/${name}`, + label: name, + provider, + })) ?? []; + return { models, result: { provider, status: "ok", detail: `${models.length} models` } }; + } catch (err) { + return { + models: [], + result: { + provider, + status: "error", + detail: err instanceof Error ? err.message : "unreachable", + }, + }; + } +} + +export async function GET() { + const [ollama, llamacpp, lmstudio, custom] = await Promise.all([ + listOllama(), + listOpenAICompatible("llamacpp", process.env.LLAMACPP_API_BASE, process.env.LLAMACPP_API_KEY), + listOpenAICompatible("lmstudio", process.env.LMSTUDIO_API_BASE, process.env.LMSTUDIO_API_KEY), + listOpenAICompatible("custom", process.env.CUSTOM_OPENAI_API_BASE, process.env.CUSTOM_OPENAI_API_KEY), + ]); + + const models = uniqueModels([...ollama.models, ...llamacpp.models, ...lmstudio.models, ...custom.models]); + const registration = await ensureLiteLLMModels(models); + + return NextResponse.json({ + models, + sources: [ollama.result, llamacpp.result, lmstudio.result, custom.result, registration], + }); +} diff --git a/docker-compose.yml b/docker-compose.yml index 276c22833..d48c4eba4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,7 @@ services: environment: - LITELLM_MASTER_KEY=${LITELLM_MASTER_KEY:-sk-decepticon-master} - LITELLM_SALT_KEY=${LITELLM_SALT_KEY:-sk-decepticon-salt} + - STORE_MODEL_IN_DB=True - CODEX_AUTH_PATH=/root/.codex/auth.json - DATABASE_URL=postgresql://decepticon:${POSTGRES_PASSWORD:-decepticon}@postgres:5432/litellm depends_on: From 8df9d80a2e3031b85662f88bd095cef1350ab210 Mon Sep 17 00:00:00 2001 From: "CONNSKILL GmbH & Co. KG" Date: Wed, 8 Jul 2026 13:17:57 +0200 Subject: [PATCH 03/10] Add engagement model switcher --- .../(dashboard)/engagements/[id]/layout.tsx | 12 + .../engagement/engagement-model-picker.tsx | 210 ++++++++++++++++++ .../src/components/terminal/web-terminal.tsx | 24 ++ 3 files changed, 246 insertions(+) create mode 100644 clients/web/src/components/engagement/engagement-model-picker.tsx diff --git a/clients/web/src/app/(dashboard)/engagements/[id]/layout.tsx b/clients/web/src/app/(dashboard)/engagements/[id]/layout.tsx index eabd5516a..79694475d 100644 --- a/clients/web/src/app/(dashboard)/engagements/[id]/layout.tsx +++ b/clients/web/src/app/(dashboard)/engagements/[id]/layout.tsx @@ -5,6 +5,7 @@ import { useParams, usePathname } from "next/navigation"; import { EngagementProvider } from "@/lib/engagement-context"; import { useRunObserver } from "@/hooks/useRunObserver"; import { WebTerminal } from "@/components/terminal/web-terminal"; +import { EngagementModelPicker } from "@/components/engagement/engagement-model-picker"; import { cn } from "@/lib/utils"; const REQUIRED_PLAN_DOCS = ["roe", "conops", "deconfliction"] as const; @@ -76,6 +77,17 @@ export default function EngagementLayout({ >
+ {engagement && ( + + setEngagement((current) => + current ? { ...current, modelOverride: modelOverride || null } : current, + ) + } + /> + )} {children}
{/* Terminal: always mounted, visibility controlled by route */} diff --git a/clients/web/src/components/engagement/engagement-model-picker.tsx b/clients/web/src/components/engagement/engagement-model-picker.tsx new file mode 100644 index 000000000..fd23cba73 --- /dev/null +++ b/clients/web/src/components/engagement/engagement-model-picker.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { RefreshCw, Save } from "lucide-react"; + +type LocalModel = { + id: string; + label: string; + provider: "ollama" | "llamacpp" | "lmstudio" | "custom"; +}; + +const BUILTIN_MODELS = [ + { value: "default", label: "Default chain" }, + { value: "auth/gpt-5.4-mini", label: "GPT OAuth Mini" }, + { value: "auth/gpt-5.4", label: "GPT OAuth" }, + { value: "openrouter/moonshotai/kimi-k2", label: "Kimi K2 via OpenRouter" }, + { value: "groq/llama-3.1-8b-instant", label: "Groq Free" }, + { value: "mittwald/Mistral-Medium-3.5-128B", label: "Mittwald" }, +] as const; + +const LOCAL_PROVIDER_LABELS: Record = { + ollama: "Ollama", + llamacpp: "llama.cpp", + lmstudio: "LM Studio", + custom: "Custom OpenAI", +}; + +function choiceFromOverride(value: string | null | undefined): string { + const trimmed = (value ?? "").trim(); + if (!trimmed) return "default"; + if (BUILTIN_MODELS.some((model) => model.value === trimmed)) return trimmed; + return "custom"; +} + +export function EngagementModelPicker({ + engagementId, + value, + onChange, +}: { + engagementId: string; + value: string; + onChange: (value: string) => void; +}) { + const [choice, setChoice] = useState(choiceFromOverride(value)); + const [customModel, setCustomModel] = useState(choiceFromOverride(value) === "custom" ? value.trim() : ""); + const [localModels, setLocalModels] = useState([]); + const [loadingModels, setLoadingModels] = useState(false); + const [saving, setSaving] = useState(false); + const [status, setStatus] = useState<"idle" | "saved" | "error">("idle"); + + useEffect(() => { + const trimmed = (value ?? "").trim(); + const localMatch = localModels.some((model) => model.id === trimmed); + const next = localMatch ? trimmed : choiceFromOverride(trimmed); + setChoice(next); + if (next === "custom") { + setCustomModel(trimmed); + } + }, [localModels, value]); + + const loadLocalModels = useCallback(async () => { + setLoadingModels(true); + try { + const res = await fetch("/api/local-models", { cache: "no-store" }); + if (!res.ok) throw new Error("failed"); + const data = (await res.json()) as { models?: LocalModel[] }; + setLocalModels(data.models ?? []); + } catch { + setLocalModels([]); + } finally { + setLoadingModels(false); + } + }, []); + + useEffect(() => { + void loadLocalModels(); + }, [loadLocalModels]); + + const localModelsByProvider = useMemo( + () => + localModels.reduce( + (groups, model) => { + const group = groups[model.provider] ?? []; + group.push(model); + groups[model.provider] = group; + return groups; + }, + {} as Partial>, + ), + [localModels], + ); + + const selectedModel = choice === "custom" ? customModel.trim() : choice === "default" ? "" : choice; + const dirty = selectedModel !== (value ?? "").trim(); + + async function save() { + if (choice === "custom" && !customModel.trim()) return; + setSaving(true); + setStatus("idle"); + try { + const res = await fetch(`/api/engagements/${engagementId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ modelOverride: selectedModel || null }), + }); + if (!res.ok) throw new Error("save failed"); + const engagement = (await res.json()) as { modelOverride?: string | null }; + onChange(engagement.modelOverride ?? ""); + setStatus("saved"); + } catch { + setStatus("error"); + } finally { + setSaving(false); + } + } + + return ( +
+ Model + + + {choice === "custom" && ( + { + setCustomModel(event.target.value); + setStatus("idle"); + }} + /> + )} + + + + {status === "saved" && Saved} + {status === "error" && Error} +
+ ); +} diff --git a/clients/web/src/components/terminal/web-terminal.tsx b/clients/web/src/components/terminal/web-terminal.tsx index f30b8314e..51b248aa3 100644 --- a/clients/web/src/components/terminal/web-terminal.tsx +++ b/clients/web/src/components/terminal/web-terminal.tsx @@ -68,6 +68,7 @@ export function WebTerminal({ const agentIdRef = useRef(agentId); agentIdRef.current = agentId; const modelOverrideRef = useRef(modelOverride ?? ""); + const lastConnectedModelOverrideRef = useRef(modelOverride ?? ""); modelOverrideRef.current = modelOverride ?? ""; const threadIdRef = useRef(threadId); threadIdRef.current = threadId; @@ -84,6 +85,7 @@ export function WebTerminal({ const resizeTimerRef = useRef | null>(null); // Track whether we've shown the reconnecting message (to avoid spam) const reconnectMsgShownRef = useRef(false); + const modelChangeReconnectRef = useRef(false); // True between sending a ping and receiving the next inbound frame; the // pong-timeout only force-closes the socket while this is still set. const awaitingPongRef = useRef(false); @@ -126,6 +128,7 @@ export function WebTerminal({ const slug = engagementSlugRef.current; const aid = agentIdRef.current; const model = modelOverrideRef.current.trim(); + lastConnectedModelOverrideRef.current = model; const tid = threadIdRef.current; let wsUrl = @@ -179,6 +182,13 @@ export function WebTerminal({ ws.onclose = (ev) => { if (disposedRef.current) return; + if (modelChangeReconnectRef.current) { + modelChangeReconnectRef.current = false; + setConnState("reconnecting"); + connectWs(); + return; + } + if (ev.code === 4001) { // Server handed this session to another connection (e.g. a second tab). // Go quiet without the "session ended" banner and without reconnecting, @@ -349,6 +359,20 @@ export function WebTerminal({ return cleanup; }, [init, cleanup]); + useEffect(() => { + const next = (modelOverride ?? "").trim(); + if (next === lastConnectedModelOverrideRef.current.trim()) return; + reconnectAttemptRef.current = 0; + reconnectMsgShownRef.current = false; + const ws = wsRef.current; + if (ws && ws.readyState === WebSocket.OPEN) { + modelChangeReconnectRef.current = true; + ws.close(1000, "model override changed"); + } else if (termRef.current) { + connectWs(); + } + }, [modelOverride, connectWs]); + // ── Heartbeat: detect silently-dead sockets ────────────────────── // Ping every 15s. A live socket answers with a pong (or is already // streaming PTY output); both clear awaitingPongRef via onmessage. Only when From 4aaa2e346acfea5307c34d08043ffa79ad1d886e Mon Sep 17 00:00:00 2001 From: "CONNSKILL GmbH & Co. KG" Date: Wed, 8 Jul 2026 14:21:07 +0200 Subject: [PATCH 04/10] Improve mobile responsiveness --- .../engagements/[id]/documents/page.tsx | 16 ++++----- .../engagements/[id]/findings/page.tsx | 12 ++++--- .../(dashboard)/engagements/[id]/layout.tsx | 10 +++--- .../engagements/[id]/live/page.tsx | 10 +++--- .../engagements/[id]/plan/page.tsx | 24 ++++++------- .../engagements/[id]/timeline/page.tsx | 10 +++--- .../app/(dashboard)/engagements/new/page.tsx | 8 ++--- .../src/app/(dashboard)/engagements/page.tsx | 14 ++++---- clients/web/src/app/(dashboard)/layout.tsx | 6 ++-- .../engagement/engagement-model-picker.tsx | 10 +++--- .../src/components/layout/command-palette.tsx | 10 +++--- clients/web/src/components/layout/header.tsx | 14 ++++---- clients/web/src/components/layout/sidebar.tsx | 35 ++++++++++++++++++- .../streaming/agent-detail-panel.tsx | 2 +- .../components/streaming/approval-gate.tsx | 8 ++--- .../streaming/live-activity-feed.tsx | 2 +- .../streaming/opplan-live-overlay.tsx | 2 +- .../src/components/terminal/web-terminal.tsx | 7 ++-- 18 files changed, 120 insertions(+), 80 deletions(-) diff --git a/clients/web/src/app/(dashboard)/engagements/[id]/documents/page.tsx b/clients/web/src/app/(dashboard)/engagements/[id]/documents/page.tsx index 52ec345a2..377b23190 100644 --- a/clients/web/src/app/(dashboard)/engagements/[id]/documents/page.tsx +++ b/clients/web/src/app/(dashboard)/engagements/[id]/documents/page.tsx @@ -173,9 +173,9 @@ export default function DocumentsPage() { const totalFiles = folders.reduce((sum, g) => sum + g.files.length, 0); return ( -
+
{/* Left: Folder tree */} -
+

Documents

@@ -247,22 +247,22 @@ export default function DocumentsPage() {

{/* Right: File content viewer */} - + - + - {selectedFile?.path ?? "Select a file"} + {selectedFile?.path ?? "Select a file"} - -
+ +
{fileLoading ? (
) : selectedFile ? ( -
+                
                   {selectedFile.content}
                 
) : ( diff --git a/clients/web/src/app/(dashboard)/engagements/[id]/findings/page.tsx b/clients/web/src/app/(dashboard)/engagements/[id]/findings/page.tsx index 2ed9f6ef1..a4ba53718 100644 --- a/clients/web/src/app/(dashboard)/engagements/[id]/findings/page.tsx +++ b/clients/web/src/app/(dashboard)/engagements/[id]/findings/page.tsx @@ -214,7 +214,7 @@ export default function FindingsPage() { return (
-
+

Findings

@@ -222,7 +222,7 @@ export default function FindingsPage() {

{ @@ -139,7 +139,7 @@ export function EngagementModelPicker({ if (open) void loadLocalModels(); }} > - + @@ -174,7 +174,7 @@ export function EngagementModelPicker({ {choice === "custom" && ( { @@ -188,6 +188,7 @@ export function EngagementModelPicker({ type="button" variant="outline" size="icon-sm" + className="w-10 sm:w-auto" onClick={() => void loadLocalModels()} disabled={loadingModels} title="Refresh models" @@ -197,6 +198,7 @@ export function EngagementModelPicker({ - Local Mode + Local Mode
); diff --git a/clients/web/src/components/layout/sidebar.tsx b/clients/web/src/components/layout/sidebar.tsx index e2c6064aa..530a16327 100644 --- a/clients/web/src/components/layout/sidebar.tsx +++ b/clients/web/src/components/layout/sidebar.tsx @@ -142,10 +142,37 @@ export function Sidebar() { return link; } + function renderMobileNavItem(item: NavItem) { + const href = resolveHref(item); + const active = isActive(item); + const disabled = item.engagementScoped && !activeEngId; + + return ( + disabled && e.preventDefault()} + > + + {item.label} + + ); + } + return ( + <>
+ + ); } diff --git a/clients/web/src/components/streaming/agent-detail-panel.tsx b/clients/web/src/components/streaming/agent-detail-panel.tsx index 03994695b..9657f9b51 100644 --- a/clients/web/src/components/streaming/agent-detail-panel.tsx +++ b/clients/web/src/components/streaming/agent-detail-panel.tsx @@ -143,7 +143,7 @@ export function AgentDetailPanel({ return (
diff --git a/clients/web/src/components/streaming/approval-gate.tsx b/clients/web/src/components/streaming/approval-gate.tsx index a998855ec..69623c677 100644 --- a/clients/web/src/components/streaming/approval-gate.tsx +++ b/clients/web/src/components/streaming/approval-gate.tsx @@ -191,13 +191,13 @@ function ApprovalCard({ request, engagementId, onDecided }: ApprovalCardProps) { {error &&

{error}

} -
+
+ + + {status === "saved" && Saved} + {status === "error" && Error} +
- - - {status === "saved" && Saved} - {status === "error" && Error} + {showRoles && ( +
+ {AGENT_ROLES.map((role) => ( +
+
+
{role.label}
+
{role.hint}
+
+ updateRole(role.value, next)} + /> +
+ ))} +
+ )}
); } diff --git a/clients/web/src/components/terminal/web-terminal.tsx b/clients/web/src/components/terminal/web-terminal.tsx index bc29f5b96..25e27445d 100644 --- a/clients/web/src/components/terminal/web-terminal.tsx +++ b/clients/web/src/components/terminal/web-terminal.tsx @@ -53,6 +53,7 @@ interface WebTerminalProps { engagementSlug: string; agentId?: string; modelOverride?: string; + modelOverrides?: Record; threadId?: string; className?: string; onThreadId?: (threadId: string) => void; @@ -63,6 +64,7 @@ export function WebTerminal({ engagementSlug, agentId = "soundwave", modelOverride, + modelOverrides, threadId, className, onThreadId, @@ -77,8 +79,11 @@ export function WebTerminal({ const agentIdRef = useRef(agentId); agentIdRef.current = agentId; const modelOverrideRef = useRef(modelOverride ?? ""); + const modelOverridesRef = useRef(modelOverrides ?? {}); const lastConnectedModelOverrideRef = useRef(modelOverride ?? ""); + const lastConnectedModelOverridesRef = useRef(JSON.stringify(modelOverrides ?? {})); modelOverrideRef.current = modelOverride ?? ""; + modelOverridesRef.current = modelOverrides ?? {}; const threadIdRef = useRef(threadId); threadIdRef.current = threadId; const onThreadIdRef = useRef(onThreadId); @@ -137,7 +142,10 @@ export function WebTerminal({ const slug = engagementSlugRef.current; const aid = agentIdRef.current; const model = modelOverrideRef.current.trim(); + const roleModels = modelOverridesRef.current; + const roleModelsJson = JSON.stringify(roleModels); lastConnectedModelOverrideRef.current = model; + lastConnectedModelOverridesRef.current = roleModelsJson; const tid = threadIdRef.current; let wsUrl = @@ -145,6 +153,9 @@ export function WebTerminal({ `&engagementSlug=${encodeURIComponent(slug)}` + `&agentId=${encodeURIComponent(aid)}`; if (model) wsUrl += `&modelOverride=${encodeURIComponent(model)}`; + if (Object.keys(roleModels).length > 0) { + wsUrl += `&modelOverrides=${encodeURIComponent(roleModelsJson)}`; + } if (tid) wsUrl += `&threadId=${encodeURIComponent(tid)}`; const ws = new WebSocket(wsUrl); @@ -370,7 +381,13 @@ export function WebTerminal({ useEffect(() => { const next = (modelOverride ?? "").trim(); - if (next === lastConnectedModelOverrideRef.current.trim()) return; + const nextRoles = JSON.stringify(modelOverrides ?? {}); + if ( + next === lastConnectedModelOverrideRef.current.trim() && + nextRoles === lastConnectedModelOverridesRef.current + ) { + return; + } reconnectAttemptRef.current = 0; reconnectMsgShownRef.current = false; const ws = wsRef.current; @@ -380,7 +397,7 @@ export function WebTerminal({ } else if (termRef.current) { connectWs(); } - }, [modelOverride, connectWs]); + }, [modelOverride, modelOverrides, connectWs]); // ── Heartbeat: detect silently-dead sockets ────────────────────── // Ping every 15s. A live socket answers with a pong (or is already diff --git a/packages/decepticon/decepticon/agents/middleware_slots.py b/packages/decepticon/decepticon/agents/middleware_slots.py index f960dd0a8..10f2a898f 100644 --- a/packages/decepticon/decepticon/agents/middleware_slots.py +++ b/packages/decepticon/decepticon/agents/middleware_slots.py @@ -234,8 +234,8 @@ def _make_opscontrol_notification(**_: Any): return OpsControlNotificationMiddleware() -def _make_model_override(**_: Any): - return ModelOverrideMiddleware() +def _make_model_override(*, role: str, **_: Any): + return ModelOverrideMiddleware(role=role) def _make_proxy_key_override(**_: Any): diff --git a/packages/decepticon/decepticon/middleware/model_override.py b/packages/decepticon/decepticon/middleware/model_override.py index f15ed8c54..3afdae098 100644 --- a/packages/decepticon/decepticon/middleware/model_override.py +++ b/packages/decepticon/decepticon/middleware/model_override.py @@ -30,6 +30,8 @@ from __future__ import annotations +import json +from collections.abc import Mapping from typing import Any from langchain.agents.middleware import AgentMiddleware @@ -44,7 +46,51 @@ log = get_logger("middleware.model_override") -def _read_override(request: Any) -> str: +def _normalize_role(role: str | None) -> str: + """Normalize role names to the env/UI spelling used for overrides.""" + return (role or "").strip().lower().replace("-", "_") + + +def _read_model_overrides_value(value: Any) -> dict[str, str]: + """Parse a role -> model map from runtime context or state.""" + if isinstance(value, str): + value = value.strip() + if not value: + return {} + try: + value = json.loads(value) + except json.JSONDecodeError: + return {} + if not isinstance(value, Mapping): + return {} + + overrides: dict[str, str] = {} + for role, model_id in value.items(): + if not isinstance(role, str) or not isinstance(model_id, str): + continue + normalized_role = _normalize_role(role) + cleaned_model_id = model_id.strip() + if normalized_role and cleaned_model_id: + overrides[normalized_role] = cleaned_model_id + return overrides + + +def _read_model_overrides(request: Any) -> dict[str, str]: + """Pull the role override map out of runtime context or input state.""" + runtime = getattr(request, "runtime", None) + if runtime is not None: + ctx = getattr(runtime, "context", None) or {} + if isinstance(ctx, dict): + overrides = _read_model_overrides_value(ctx.get("model_overrides", {})) + if overrides: + return overrides + + state = getattr(request, "state", None) or {} + get = state.get if hasattr(state, "get") else (lambda _k, _d=None: None) + return _read_model_overrides_value(get("model_overrides", {})) + + +def _read_global_override(request: Any) -> str: """Pull the override id out of runtime context or input state. Returns the empty string when nothing is set so the caller can @@ -63,6 +109,21 @@ def _read_override(request: Any) -> str: return value.strip() if isinstance(value, str) else "" +def _read_override(request: Any, role: str | None = None) -> str: + """Resolve the effective override id for one agent role. + + Role-specific overrides win over the engagement-wide/global override. + A ``default`` entry in the role map can act as a map-local fallback. + """ + normalized_role = _normalize_role(role) + overrides = _read_model_overrides(request) + if normalized_role and normalized_role in overrides: + return overrides[normalized_role] + if "default" in overrides: + return overrides["default"] + return _read_global_override(request) + + def _build_proxied_llm(model_id: str, original: BaseChatModel) -> BaseChatModel: """Construct a ChatOpenAI bound to the LiteLLM proxy for ``model_id``. @@ -106,9 +167,13 @@ class ModelOverrideMiddleware(AgentMiddleware): and the existing fallback chain still applies on its failure. """ + def __init__(self, role: str | None = None) -> None: + super().__init__() + self.role = _normalize_role(role) + @override def wrap_model_call(self, request, handler): - override_id = _read_override(request) + override_id = _read_override(request, self.role) if not override_id: return handler(request) try: @@ -116,12 +181,12 @@ def wrap_model_call(self, request, handler): except Exception as exc: log.warning("model_override %s failed to bind: %s", override_id, exc) return handler(request) - log.info("model_override active: %s", override_id) + log.info("model_override active%s: %s", f" for {self.role}" if self.role else "", override_id) return handler(request.override(model=new_llm)) @override async def awrap_model_call(self, request, handler): - override_id = _read_override(request) + override_id = _read_override(request, self.role) if not override_id: return await handler(request) try: @@ -129,7 +194,7 @@ async def awrap_model_call(self, request, handler): except Exception as exc: log.warning("model_override %s failed to bind: %s", override_id, exc) return await handler(request) - log.info("model_override active: %s", override_id) + log.info("model_override active%s: %s", f" for {self.role}" if self.role else "", override_id) return await handler(request.override(model=new_llm)) diff --git a/packages/decepticon/tests/unit/middleware/test_model_override.py b/packages/decepticon/tests/unit/middleware/test_model_override.py index 822d4261b..2089a17e3 100644 --- a/packages/decepticon/tests/unit/middleware/test_model_override.py +++ b/packages/decepticon/tests/unit/middleware/test_model_override.py @@ -227,6 +227,62 @@ class _Req: assert _read_override(_Req()) == "openai/gpt-5.5" + def test_role_override_takes_priority_over_global(self) -> None: + class _Runtime: + context = { + "model_override": "openai/gpt-5.5", + "model_overrides": {"recon": "ollama_chat/qwen2.5-coder:7b"}, + } + + class _Req: + runtime = _Runtime() + state: dict[str, Any] = {} + + assert _read_override(_Req(), "recon") == "ollama_chat/qwen2.5-coder:7b" + + def test_role_override_normalizes_hyphenated_role_names(self) -> None: + class _Runtime: + context = {"model_overrides": {"blue_cell": "mittwald/Mistral-Medium-3.5-128B"}} + + class _Req: + runtime = _Runtime() + state: dict[str, Any] = {} + + assert _read_override(_Req(), "blue-cell") == "mittwald/Mistral-Medium-3.5-128B" + + def test_default_role_override_falls_back_before_global(self) -> None: + class _Runtime: + context = { + "model_override": "openai/gpt-5.5", + "model_overrides": {"default": "groq/llama-3.1-8b-instant"}, + } + + class _Req: + runtime = _Runtime() + state: dict[str, Any] = {} + + assert _read_override(_Req(), "recon") == "groq/llama-3.1-8b-instant" + + def test_state_role_override_used_when_runtime_absent(self) -> None: + class _Req: + runtime = None + state = { + "model_override": "openai/gpt-5.4", + "model_overrides": {"exploit": "openrouter/moonshotai/kimi-k2"}, + } + + assert _read_override(_Req(), "exploit") == "openrouter/moonshotai/kimi-k2" + + def test_json_encoded_role_overrides_are_supported(self) -> None: + class _Runtime: + context = {"model_overrides": '{"analyst":"auth/gpt-5.4-mini"}'} + + class _Req: + runtime = _Runtime() + state: dict[str, Any] = {} + + assert _read_override(_Req(), "analyst") == "auth/gpt-5.4-mini" + def test_state_used_when_runtime_absent(self) -> None: class _Req: runtime = None From d5b25ec1c37cf29a0a5ff0274bb166a269893918 Mon Sep 17 00:00:00 2001 From: "CONNSKILL GmbH & Co. KG" Date: Wed, 8 Jul 2026 16:36:33 +0200 Subject: [PATCH 07/10] Add optional Mullvad sandbox egress overlay --- .env.example | 18 +++++++ docker-compose.mullvad.yml | 63 +++++++++++++++++++++++ docs/ops/mullvad-sandbox-egress.md | 82 ++++++++++++++++++++++++++++++ 3 files changed, 163 insertions(+) create mode 100644 docker-compose.mullvad.yml create mode 100644 docs/ops/mullvad-sandbox-egress.md diff --git a/.env.example b/.env.example index 581575a5a..213b1412d 100644 --- a/.env.example +++ b/.env.example @@ -110,6 +110,24 @@ CLOUDFLARE_AI_GATEWAY_API_KEY=your-cloudflare-ai-gateway-key-here # MITTWALD_AI_KEY=... # MITTWALD_AI_MODEL=Mistral-Medium-3.5-128B +# --- Optional Mullvad egress for the sandbox only --- +# Enable with docker-compose.mullvad.yml. This does NOT route the web UI, +# LiteLLM, Postgres, Neo4j, or Tailscale through Mullvad; only the sandbox +# shares the VPN gateway network namespace. +# +# Values come from a Mullvad WireGuard configuration. The private key is the +# `PrivateKey` from the downloaded WireGuard config JSON/conf, not the public +# "WireGuard key" displayed on the device page. +# MULLVAD_WIREGUARD_PRIVATE_KEY= +# MULLVAD_WIREGUARD_ADDRESSES=10.x.y.z/32 +# MULLVAD_SERVER_COUNTRIES=Germany +# MULLVAD_SERVER_CITIES= +# MULLVAD_SERVER_HOSTNAMES= +# Keep this broad default for Docker Desktop control-plane reachability. Set +# it to the exact sandbox-net subnet if your in-scope target is on a private +# RFC1918 network and you need that target traffic to go through the VPN. +# MULLVAD_LOCAL_SUBNETS=172.16.0.0/12,10.0.0.0/8,192.168.0.0/16 + # --- Local LLM (Ollama) --- # Set both to run Decepticon against a local Ollama model. The # launcher's onboard wizard writes these when you pick "Local LLM". diff --git a/docker-compose.mullvad.yml b/docker-compose.mullvad.yml new file mode 100644 index 000000000..4bbccf6e5 --- /dev/null +++ b/docker-compose.mullvad.yml @@ -0,0 +1,63 @@ +# Optional overlay: route only the sandbox container through Mullvad. +# +# Usage: +# docker compose --env-file .env \ +# -f docker-compose.yml -f docker-compose.lan.yml -f docker-compose.mullvad.yml \ +# --profile cli --profile web up -d --force-recreate mullvad-gateway sandbox langgraph web +# +# Web/Tailscale stay on their normal Docker networks. Only the sandbox shares +# the VPN gateway network namespace, so target-facing tool egress goes out via +# Mullvad while the management UI remains reachable. + +services: + mullvad-gateway: + image: ${GLUETUN_IMAGE:-qmcgaw/gluetun:latest} + container_name: decepticon${DECEPTICON_STACK_NAME:+-${DECEPTICON_STACK_NAME}}-mullvad + cap_add: + - NET_ADMIN + devices: + - /dev/net/tun:/dev/net/tun + environment: + VPN_SERVICE_PROVIDER: mullvad + VPN_TYPE: wireguard + WIREGUARD_PRIVATE_KEY: ${MULLVAD_WIREGUARD_PRIVATE_KEY:?Set MULLVAD_WIREGUARD_PRIVATE_KEY in .env} + WIREGUARD_ADDRESSES: ${MULLVAD_WIREGUARD_ADDRESSES:?Set MULLVAD_WIREGUARD_ADDRESSES in .env} + SERVER_COUNTRIES: ${MULLVAD_SERVER_COUNTRIES:-Germany} + SERVER_CITIES: ${MULLVAD_SERVER_CITIES:-} + SERVER_HOSTNAMES: ${MULLVAD_SERVER_HOSTNAMES:-} + FIREWALL: "on" + # LangGraph reaches the sandbox daemon through this port on sandbox-net. + FIREWALL_INPUT_PORTS: "9999" + # Keep Docker/private control-plane traffic local. Tighten this to the + # exact sandbox-net subnet if you need private target ranges to traverse + # the VPN instead of staying local. + FIREWALL_OUTBOUND_SUBNETS: ${MULLVAD_LOCAL_SUBNETS:-172.16.0.0/12,10.0.0.0/8,192.168.0.0/16} + # Gluetun defaults its own health server to 127.0.0.1:9999, which + # collides with Decepticon's sandbox daemon when both share a network + # namespace. Move Gluetun's internal health endpoint aside. + HEALTH_SERVER_ADDRESS: 127.0.0.1:9998 + TZ: ${TZ:-Europe/Berlin} + networks: + sandbox-net: + aliases: + # Preserve SANDBOX_URL=http://sandbox:9999 without putting the + # sandbox service itself on a second Docker network. + - sandbox + restart: unless-stopped + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + sandbox: + # The sandbox process keeps its filesystem, caps, workspace mount and + # daemon, but shares mullvad-gateway's network namespace. Compose forbids + # `networks` together with `network_mode`, so override the base networks. + networks: !override [] + network_mode: "service:mullvad-gateway" + depends_on: + mullvad-gateway: + condition: service_started + neo4j: + condition: service_healthy diff --git a/docs/ops/mullvad-sandbox-egress.md b/docs/ops/mullvad-sandbox-egress.md new file mode 100644 index 000000000..cf24191a8 --- /dev/null +++ b/docs/ops/mullvad-sandbox-egress.md @@ -0,0 +1,82 @@ +# Mullvad Sandbox Egress + +This optional setup routes only the Decepticon sandbox through Mullvad. +The web dashboard, LiteLLM, Postgres, Neo4j, and Tailscale access remain +on their normal Docker networks. + +## Configure + +Add the Mullvad WireGuard values to `.env`: + +```env +MULLVAD_WIREGUARD_PRIVATE_KEY=... +MULLVAD_WIREGUARD_ADDRESSES=10.x.y.z/32 +MULLVAD_SERVER_COUNTRIES=Germany +MULLVAD_SERVER_CITIES= +MULLVAD_SERVER_HOSTNAMES= +MULLVAD_LOCAL_SUBNETS=172.16.0.0/12,10.0.0.0/8,192.168.0.0/16 +``` + +`MULLVAD_WIREGUARD_PRIVATE_KEY` is the `PrivateKey` from a downloaded +Mullvad WireGuard configuration. It is not the public device key shown +on the Mullvad device page. + +`MULLVAD_WIREGUARD_ADDRESSES` is the IPv4 address from the same config, +usually the first value in `Address`. + +## Start + +```bash +docker compose --env-file .env \ + -f docker-compose.yml \ + -f docker-compose.lan.yml \ + -f docker-compose.mullvad.yml \ + --profile cli --profile web \ + up -d --force-recreate mullvad-gateway sandbox langgraph web +``` + +The overlay makes `sandbox` share `mullvad-gateway`'s network namespace +and assigns the gateway the Docker DNS alias `sandbox`. Existing code can +keep using `SANDBOX_URL=http://sandbox:9999`. + +## Verify + +Compare host/web egress and sandbox egress: + +```bash +curl https://am.i.mullvad.net/json +docker exec decepticon-sandbox curl -s https://am.i.mullvad.net/json +``` + +The host command should show the host route. The sandbox command should +show Mullvad. + +Check the VPN gateway logs: + +```bash +docker logs decepticon-mullvad --tail 120 +``` + +## Local subnet note + +The sandbox still needs local Docker traffic for: + +- `langgraph -> sandbox:9999` +- `sandbox -> neo4j:7687` +- Docker DNS on the bridge network + +`MULLVAD_LOCAL_SUBNETS` defaults to broad private ranges because Docker +Desktop bridge subnets differ across machines. If the engagement target +is also on a private RFC1918 subnet and you want that target traffic to +go through Mullvad, replace the default with the exact Docker sandbox +network subnet: + +```bash +docker network inspect decepticon_sandbox-net --format '{{json .IPAM.Config}}' +``` + +Then set, for example: + +```env +MULLVAD_LOCAL_SUBNETS=172.23.0.0/16 +``` From b6ddda27e6d8269001d18265b7222097677582ff Mon Sep 17 00:00:00 2001 From: "CONNSKILL GmbH & Co. KG" Date: Wed, 8 Jul 2026 18:21:42 +0200 Subject: [PATCH 08/10] Restrict LAN bindings to Tailscale --- docker-compose.lan.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docker-compose.lan.yml b/docker-compose.lan.yml index 138fe500e..1709f8625 100644 --- a/docker-compose.lan.yml +++ b/docker-compose.lan.yml @@ -1,13 +1,14 @@ services: langgraph: ports: !override - - "0.0.0.0:${LANGGRAPH_PORT:-2024}:2024" + - "127.0.0.1:${LANGGRAPH_PORT:-2024}:2024" + - "100.84.167.36:${LANGGRAPH_PORT:-2024}:2024" web: ports: !override - - "0.0.0.0:${WEB_PORT:-3000}:3000" - - "0.0.0.0:${TERMINAL_PORT:-3003}:3003" + - "127.0.0.1:${WEB_PORT:-3000}:3000" + - "100.84.167.36:${WEB_PORT:-3000}:3000" environment: NEXT_PUBLIC_LANGGRAPH_API_URL: "http://100.84.167.36:${LANGGRAPH_PORT:-2024}" - NEXT_PUBLIC_TERMINAL_WS_URL: "ws://100.84.167.36:${TERMINAL_PORT:-3003}" - TERMINAL_ALLOWED_ORIGINS: "http://100.84.167.36:${WEB_PORT:-3000},http://192.168.178.26:${WEB_PORT:-3000},http://localhost:${WEB_PORT:-3000},http://127.0.0.1:${WEB_PORT:-3000}" + NEXT_PUBLIC_TERMINAL_WS_URL: "wss://connskill.tail4ee3fd.ts.net/terminal" + TERMINAL_ALLOWED_ORIGINS: "https://connskill.tail4ee3fd.ts.net,http://100.84.167.36:${WEB_PORT:-3000},http://localhost:${WEB_PORT:-3000},http://127.0.0.1:${WEB_PORT:-3000}" From 1aab3e63e1d960a70b77b97cd7e0f469c2ccc131 Mon Sep 17 00:00:00 2001 From: "CONNSKILL GmbH & Co. KG" Date: Wed, 8 Jul 2026 18:40:59 +0200 Subject: [PATCH 09/10] Add configurable model block policy --- clients/web/server/terminal-server.ts | 37 ++- .../(dashboard)/engagements/[id]/layout.tsx | 49 ++-- .../engagements/[id]/live/page.tsx | 6 +- .../app/(dashboard)/engagements/new/page.tsx | 196 +++++++--------- .../web/src/app/api/engagements/[id]/route.ts | 28 +-- clients/web/src/app/api/engagements/route.ts | 21 +- clients/web/src/app/api/model-policy/route.ts | 42 ++++ .../engagement/engagement-model-picker.tsx | 216 ++++++++++++++++-- clients/web/src/components/layout/header.tsx | 7 +- clients/web/src/lib/model-options.ts | 52 +++++ clients/web/src/lib/model-policy.ts | 85 +++++++ 11 files changed, 550 insertions(+), 189 deletions(-) create mode 100644 clients/web/src/app/api/model-policy/route.ts create mode 100644 clients/web/src/lib/model-options.ts create mode 100644 clients/web/src/lib/model-policy.ts diff --git a/clients/web/server/terminal-server.ts b/clients/web/server/terminal-server.ts index 8b5b9e564..eb345db1f 100755 --- a/clients/web/server/terminal-server.ts +++ b/clients/web/server/terminal-server.ts @@ -29,7 +29,8 @@ import { WebSocketServer, WebSocket } from "ws"; import * as pty from "node-pty"; -import { resolve, dirname } from "path"; +import * as fs from "fs"; +import { resolve, dirname, join } from "path"; import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); @@ -41,7 +42,7 @@ const CLI_PATH = resolve(__dirname, "../../cli/src/index.tsx"); const LANGGRAPH_API_URL = process.env.LANGGRAPH_API_URL ?? "http://localhost:2024"; const ORPHAN_TTL = 60_000; // Kill orphaned PTYs after 60s with no WS const SCROLLBACK_LIMIT = 50_000; // chars of recent output to buffer for reattach -const BLOCKED_MODEL_ID_PARTS = ["wormgpt", "uncensored", "abliterate"]; +const DEFAULT_BLOCKED_MODEL_PATTERNS = ["wormgpt", "uncensored", "abliterate"]; const ALLOWED_ORIGINS = new Set( (process.env.TERMINAL_ALLOWED_ORIGINS ?? `http://localhost:${WEB_PORT},http://127.0.0.1:${WEB_PORT}`) @@ -134,8 +135,38 @@ function persistThreadId(engagementId: string, threadId: string): void { } function isBlockedModelId(value: string): boolean { + const blockedPatterns = readBlockedModelPatterns(); const lower = value.toLowerCase(); - return BLOCKED_MODEL_ID_PARTS.some((part) => lower.includes(part)); + return blockedPatterns.some((part) => lower.includes(part.toLowerCase())); +} + +function modelPolicyPath(): string { + return ( + process.env.MODEL_POLICY_PATH ?? + join(process.env.WORKSPACE_PATH ?? "/workspace", ".decepticon", "model-policy.json") + ); +} + +function readBlockedModelPatterns(): string[] { + try { + const parsed = JSON.parse(fs.readFileSync(modelPolicyPath(), "utf8")) as { + blockedPatterns?: unknown; + }; + const values = Array.isArray(parsed.blockedPatterns) ? parsed.blockedPatterns : []; + const seen = new Set(); + return values + .filter((value): value is string => typeof value === "string") + .map((value) => value.trim()) + .filter(Boolean) + .filter((pattern) => { + const key = pattern.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + } catch { + return [...DEFAULT_BLOCKED_MODEL_PATTERNS]; + } } function normalizeModelOverridesJson(raw: string): string { diff --git a/clients/web/src/app/(dashboard)/engagements/[id]/layout.tsx b/clients/web/src/app/(dashboard)/engagements/[id]/layout.tsx index 165d2cee2..fd8275575 100644 --- a/clients/web/src/app/(dashboard)/engagements/[id]/layout.tsx +++ b/clients/web/src/app/(dashboard)/engagements/[id]/layout.tsx @@ -84,8 +84,8 @@ export default function EngagementLayout({ isRunning={isRunning} activeRunId={activeRunId} > -
-
+
+
{engagement && ( )} - {children}
- {/* Terminal: always mounted, visibility controlled by route */}
- {terminalReady && ( - - )} +
+ {children} +
+ {/* Terminal: always mounted, visibility controlled by route */} +
+ {terminalReady && ( + + )} +
diff --git a/clients/web/src/app/(dashboard)/engagements/[id]/live/page.tsx b/clients/web/src/app/(dashboard)/engagements/[id]/live/page.tsx index 86d67c1f2..48f74225a 100644 --- a/clients/web/src/app/(dashboard)/engagements/[id]/live/page.tsx +++ b/clients/web/src/app/(dashboard)/engagements/[id]/live/page.tsx @@ -29,9 +29,9 @@ export default function LivePage() { } return ( -
+
{/* Left: Activity Feed */} -
+
{selectedAgent && (
@@ -45,7 +45,7 @@ export default function LivePage() {
{/* Center: Agent Execution Graph + OPPLAN overlay */} -
+
= { ollama: "Ollama", llamacpp: "llama.cpp", @@ -43,18 +37,9 @@ const LOCAL_PROVIDER_LABELS: Record = { custom: "Custom OpenAI", }; -function toLocalModelOverride(value: string): string { - const trimmed = value.trim(); - if (!trimmed) return ""; - if ( - trimmed.startsWith("ollama_chat/") || - trimmed.startsWith("llamacpp/") || - trimmed.startsWith("lm_studio/") || - trimmed.startsWith("custom/") - ) { - return trimmed; - } - return `ollama_chat/${trimmed.replace(/^ollama(?:_chat)?\//, "")}`; +function isBlockedModel(id: string, patterns: string[]): boolean { + const lower = id.toLowerCase(); + return patterns.some((pattern) => lower.includes(pattern.toLowerCase())); } export default function NewEngagementPage() { @@ -63,9 +48,10 @@ export default function NewEngagementPage() { const [name, setName] = useState(""); const [targetValue, setTargetValue] = useState(""); const [modelChoice, setModelChoice] = useState("default"); - const [mittwaldModel, setMittwaldModel] = useState("Mistral-Medium-3.5-128B"); - const [localModel, setLocalModel] = useState("ollama_chat/qwen2.5-coder:7b"); const [localModels, setLocalModels] = useState([]); + const [modelPolicy, setModelPolicy] = useState({ + blockedPatterns: ["wormgpt", "uncensored", "abliterate"], + }); const [localModelsLoading, setLocalModelsLoading] = useState(false); const [customModel, setCustomModel] = useState(""); const [submitting, setSubmitting] = useState(false); @@ -77,22 +63,32 @@ export default function NewEngagementPage() { const res = await fetch("/api/local-models", { cache: "no-store" }); if (!res.ok) throw new Error("Failed to load local models"); const data = (await res.json()) as { models?: LocalModel[] }; - const models = data.models ?? []; - setLocalModels(models); - if (models.length > 0 && !models.some((model) => model.id === localModel)) { - setLocalModel(models[0].id); - } + setLocalModels(data.models ?? []); } catch { setLocalModels([]); } finally { setLocalModelsLoading(false); } - }, [localModel]); + }, []); useEffect(() => { void loadLocalModels(); }, [loadLocalModels]); + const loadModelPolicy = useCallback(async () => { + try { + const res = await fetch("/api/model-policy", { cache: "no-store" }); + if (!res.ok) throw new Error("Failed to load model policy"); + setModelPolicy((await res.json()) as ModelPolicy); + } catch { + setModelPolicy({ blockedPatterns: ["wormgpt", "uncensored", "abliterate"] }); + } + }, []); + + useEffect(() => { + void loadModelPolicy(); + }, [loadModelPolicy]); + const localModelsByProvider = useMemo( () => localModels.reduce( @@ -116,22 +112,16 @@ export default function NewEngagementPage() { const modelOverride = modelChoice === "default" ? "" - : modelChoice === "ollama-local" - ? toLocalModelOverride(localModel) - : modelChoice === "mittwald" - ? mittwaldModel.trim() - ? `mittwald/${mittwaldModel.trim().replace(/^mittwald\//, "")}` - : "" - : modelChoice === "custom" - ? customModel.trim() - : modelChoice; + : modelChoice === "custom" + ? customModel.trim() + : modelChoice; async function handleSubmit() { if (!nameValid || !targetValue.trim()) { setError("Please fill in all required fields"); return; } - if ((modelChoice === "mittwald" || modelChoice === "ollama-local" || modelChoice === "custom") && !modelOverride) { + if (modelChoice === "custom" && !modelOverride) { setError("Please provide a model id"); return; } @@ -211,89 +201,65 @@ export default function NewEngagementPage() {
- +
+ + +
-
- - {modelChoice === "ollama-local" && ( -
-
- - -
- {localModels.length > 0 ? ( - - ) : ( - setLocalModel(e.target.value)} - /> - )} -
- )} - - {modelChoice === "mittwald" && ( -
- - setMittwaldModel(e.target.value)} - /> -
- )} + + ))} + {(Object.keys(LOCAL_PROVIDER_LABELS) as LocalModel["provider"][]).map((provider) => { + const models = localModelsByProvider[provider] ?? []; + if (models.length === 0) return null; + return ( + + {LOCAL_PROVIDER_LABELS[provider]} + {models.map((model) => { + const blocked = isBlockedModel(model.id, modelPolicy.blockedPatterns); + return ( + + {blocked ? `${model.label} (gesperrt)` : model.label} + + ); + })} + + ); + })} + + Manual + Custom model ID + + + +
{modelChoice === "custom" && (
diff --git a/clients/web/src/app/api/engagements/[id]/route.ts b/clients/web/src/app/api/engagements/[id]/route.ts index 4416cc1b6..349516fc6 100644 --- a/clients/web/src/app/api/engagements/[id]/route.ts +++ b/clients/web/src/app/api/engagements/[id]/route.ts @@ -1,15 +1,9 @@ import { requireAuth, AuthError } from "@/lib/auth-bridge"; +import { assertModelAllowed } from "@/lib/model-policy"; import { prisma } from "@/lib/prisma"; import { SLUG_RE, VALID_TARGET_TYPES, VALID_STATUSES } from "@/lib/workspace"; import { NextRequest, NextResponse } from "next/server"; -const BLOCKED_MODEL_ID_PARTS = ["wormgpt", "uncensored", "abliterate"]; - -function isBlockedModelId(value: string): boolean { - const lower = value.toLowerCase(); - return BLOCKED_MODEL_ID_PARTS.some((part) => lower.includes(part)); -} - export async function GET( _req: NextRequest, { params }: { params: Promise<{ id: string }> } @@ -110,11 +104,15 @@ export async function PATCH( { error: "Invalid modelOverride. Must be a model id up to 200 chars" }, { status: 400 } ); - } else if (isBlockedModelId(data.modelOverride)) { - return NextResponse.json( - { error: "Blocked model id is not allowed for this application" }, - { status: 400 } - ); + } else { + try { + await assertModelAllowed(data.modelOverride); + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : "Blocked model id is not allowed" }, + { status: 400 } + ); + } } } @@ -146,9 +144,11 @@ export async function PATCH( ); } const trimmed = model.trim(); - if (trimmed && isBlockedModelId(trimmed)) { + try { + await assertModelAllowed(trimmed); + } catch (e) { return NextResponse.json( - { error: "Blocked model id is not allowed for this application" }, + { error: e instanceof Error ? e.message : "Blocked model id is not allowed" }, { status: 400 }, ); } diff --git a/clients/web/src/app/api/engagements/route.ts b/clients/web/src/app/api/engagements/route.ts index b637966d5..d4ec322f7 100644 --- a/clients/web/src/app/api/engagements/route.ts +++ b/clients/web/src/app/api/engagements/route.ts @@ -1,4 +1,5 @@ import { requireAuth, AuthError } from "@/lib/auth-bridge"; +import { assertModelAllowed } from "@/lib/model-policy"; import { prisma } from "@/lib/prisma"; import { SLUG_RE, VALID_TARGET_TYPES } from "@/lib/workspace"; import { NextRequest, NextResponse } from "next/server"; @@ -8,14 +9,8 @@ import * as path from "path"; const WORKSPACE = process.env.WORKSPACE_PATH ?? path.join(process.env.HOME ?? "", ".decepticon", "workspace"); const WORKSPACE_SUBDIRS = ["plan"]; -const BLOCKED_MODEL_ID_PARTS = ["wormgpt", "uncensored", "abliterate"]; -function isBlockedModelId(value: string): boolean { - const lower = value.toLowerCase(); - return BLOCKED_MODEL_ID_PARTS.some((part) => lower.includes(part)); -} - -function normalizeModelOverrides(value: unknown): Record | null { +async function normalizeModelOverrides(value: unknown): Promise | null> { if (value == null || value === "") return null; if (typeof value !== "object" || Array.isArray(value)) { throw new Error("Invalid modelOverrides. Must be an object of role to model id"); @@ -30,9 +25,7 @@ function normalizeModelOverrides(value: unknown): Record | null throw new Error("Invalid modelOverrides model id. Must be up to 200 chars"); } const trimmed = model.trim(); - if (trimmed && isBlockedModelId(trimmed)) { - throw new Error("Blocked model id is not allowed for this application"); - } + await assertModelAllowed(trimmed); if (trimmed) clean[role] = trimmed; } return Object.keys(clean).length > 0 ? clean : null; @@ -104,7 +97,7 @@ export async function POST(req: NextRequest) { : null; let modelOverrides: Record | null = null; try { - modelOverrides = normalizeModelOverrides(body.modelOverrides); + modelOverrides = await normalizeModelOverrides(body.modelOverrides); } catch (e) { return NextResponse.json( { error: e instanceof Error ? e.message : "Invalid modelOverrides" }, @@ -132,9 +125,11 @@ export async function POST(req: NextRequest) { { status: 400 } ); } - if (modelOverride && isBlockedModelId(modelOverride)) { + try { + await assertModelAllowed(modelOverride ?? ""); + } catch (e) { return NextResponse.json( - { error: "Blocked model id is not allowed for this application" }, + { error: e instanceof Error ? e.message : "Blocked model id is not allowed" }, { status: 400 } ); } diff --git a/clients/web/src/app/api/model-policy/route.ts b/clients/web/src/app/api/model-policy/route.ts new file mode 100644 index 000000000..9da4b44ba --- /dev/null +++ b/clients/web/src/app/api/model-policy/route.ts @@ -0,0 +1,42 @@ +import { requireAuth, AuthError } from "@/lib/auth-bridge"; +import { readModelPolicy, writeModelPolicy } from "@/lib/model-policy"; +import { NextRequest, NextResponse } from "next/server"; + +export async function GET() { + try { + await requireAuth(); + return NextResponse.json(await readModelPolicy()); + } catch (e) { + if (e instanceof AuthError) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + console.error("GET /api/model-policy error:", e); + return NextResponse.json( + { error: e instanceof Error ? e.message : "Internal server error" }, + { status: 500 }, + ); + } +} + +export async function PATCH(req: NextRequest) { + try { + await requireAuth(); + const body = await req.json(); + if (!Array.isArray(body.blockedPatterns)) { + return NextResponse.json( + { error: "blockedPatterns must be an array" }, + { status: 400 }, + ); + } + return NextResponse.json(await writeModelPolicy(body.blockedPatterns)); + } catch (e) { + if (e instanceof AuthError) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + console.error("PATCH /api/model-policy error:", e); + return NextResponse.json( + { error: e instanceof Error ? e.message : "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/clients/web/src/components/engagement/engagement-model-picker.tsx b/clients/web/src/components/engagement/engagement-model-picker.tsx index 9521c63ce..c7d5a7469 100644 --- a/clients/web/src/components/engagement/engagement-model-picker.tsx +++ b/clients/web/src/components/engagement/engagement-model-picker.tsx @@ -12,7 +12,8 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { RefreshCw, Save, SlidersHorizontal } from "lucide-react"; +import { BUILTIN_MODEL_GROUPS, BUILTIN_MODELS, DEFAULT_MODEL_OPTION } from "@/lib/model-options"; +import { Ban, Check, RefreshCw, Save, Shield, SlidersHorizontal, X } from "lucide-react"; type LocalModel = { id: string; @@ -22,14 +23,9 @@ type LocalModel = { export type ModelOverrides = Record; -const BUILTIN_MODELS = [ - { value: "default", label: "Default chain" }, - { value: "auth/gpt-5.4-mini", label: "GPT OAuth Mini" }, - { value: "auth/gpt-5.4", label: "GPT OAuth" }, - { value: "openrouter/moonshotai/kimi-k2", label: "Kimi K2 via OpenRouter" }, - { value: "groq/llama-3.1-8b-instant", label: "Groq Free" }, - { value: "mittwald/Mistral-Medium-3.5-128B", label: "Mittwald" }, -] as const; +type ModelPolicy = { + blockedPatterns: string[]; +}; const AGENT_ROLES = [ { value: "soundwave", label: "Soundwave", hint: "planning" }, @@ -76,9 +72,23 @@ function normalizeOverrides(value: ModelOverrides | null | undefined): ModelOver ); } -function isBlockedModel(id: string): boolean { +function isBlockedModel(id: string, patterns: string[]): boolean { const lower = id.toLowerCase(); - return lower.includes("wormgpt") || lower.includes("uncensored") || lower.includes("abliterate"); + return patterns.some((pattern) => lower.includes(pattern.toLowerCase())); +} + +function uniquePatterns(patterns: string[]): string[] { + const seen = new Set(); + const clean: string[] = []; + for (const pattern of patterns) { + const trimmed = pattern.trim(); + if (!trimmed) continue; + const key = trimmed.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + clean.push(trimmed); + } + return clean; } function isKnownModelChoice( @@ -93,10 +103,12 @@ function isKnownModelChoice( function RoleModelSelect({ value, + blockedPatterns, localModelsByProvider, onChange, }: { value: string; + blockedPatterns: string[]; localModelsByProvider: Partial>; onChange: (value: string) => void; }) { @@ -128,13 +140,22 @@ function RoleModelSelect({ - Cloud - {BUILTIN_MODELS.map((model) => ( - - {model.label} - - ))} + Default + {DEFAULT_MODEL_OPTION.label} + {BUILTIN_MODEL_GROUPS.map((group) => ( + + {group.label} + {group.models.map((model) => { + const blocked = isBlockedModel(model.value, blockedPatterns); + return ( + + {blocked ? `${model.label} (gesperrt)` : model.label} + + ); + })} + + ))} {(Object.keys(LOCAL_PROVIDER_LABELS) as LocalModel["provider"][]).map((provider) => { const models = localModelsByProvider[provider] ?? []; if (models.length === 0) return null; @@ -142,10 +163,10 @@ function RoleModelSelect({ {LOCAL_PROVIDER_LABELS[provider]} {models.map((model) => { - const blocked = isBlockedModel(model.id); + const blocked = isBlockedModel(model.id, blockedPatterns); return ( - {blocked ? `${model.label} (blocked)` : model.label} + {blocked ? `${model.label} (gesperrt)` : model.label} ); })} @@ -185,7 +206,13 @@ export function EngagementModelPicker({ const [customModel, setCustomModel] = useState(choiceFromOverride(value) === "custom" ? value.trim() : ""); const [roleOverrides, setRoleOverrides] = useState(() => normalizeOverrides(overrides)); const [showRoles, setShowRoles] = useState(false); + const [showPolicy, setShowPolicy] = useState(false); const [localModels, setLocalModels] = useState([]); + const [modelPolicy, setModelPolicy] = useState({ + blockedPatterns: ["wormgpt", "uncensored", "abliterate"], + }); + const [policyDraft, setPolicyDraft] = useState(""); + const [savingPolicy, setSavingPolicy] = useState(false); const [loadingModels, setLoadingModels] = useState(false); const [saving, setSaving] = useState(false); const [status, setStatus] = useState<"idle" | "saved" | "error">("idle"); @@ -220,6 +247,22 @@ export function EngagementModelPicker({ void loadLocalModels(); }, [loadLocalModels]); + const loadModelPolicy = useCallback(async () => { + try { + const res = await fetch("/api/model-policy", { cache: "no-store" }); + if (!res.ok) throw new Error("failed"); + setModelPolicy((await res.json()) as ModelPolicy); + } catch { + setModelPolicy({ + blockedPatterns: ["wormgpt", "uncensored", "abliterate"], + }); + } + }, []); + + useEffect(() => { + void loadModelPolicy(); + }, [loadModelPolicy]); + const localModelsByProvider = useMemo( () => localModels.reduce( @@ -236,10 +279,60 @@ export function EngagementModelPicker({ const selectedModel = choice === "custom" ? customModel.trim() : choice === "default" ? "" : choice; const cleanOverrides = normalizeOverrides(roleOverrides); + const blockedPatterns = modelPolicy.blockedPatterns; const dirty = selectedModel !== (value ?? "").trim() || JSON.stringify(cleanOverrides) !== JSON.stringify(normalizeOverrides(overrides)); + const selectableModels = useMemo( + () => [ + ...BUILTIN_MODELS.filter((model) => model.value !== "default").map((model) => ({ + id: model.value, + label: model.label, + provider: "cloud", + })), + ...localModels.map((model) => ({ + id: model.id, + label: model.label, + provider: LOCAL_PROVIDER_LABELS[model.provider], + })), + ], + [localModels], + ); + + async function savePolicy(nextPatterns: string[]) { + setSavingPolicy(true); + setStatus("idle"); + try { + const res = await fetch("/api/model-policy", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ blockedPatterns: uniquePatterns(nextPatterns) }), + }); + if (!res.ok) throw new Error("save failed"); + setModelPolicy((await res.json()) as ModelPolicy); + } catch { + setStatus("error"); + } finally { + setSavingPolicy(false); + } + } + + function blockModel(id: string) { + void savePolicy([...blockedPatterns, id]); + } + + function freeModel(id: string) { + const lower = id.toLowerCase(); + void savePolicy(blockedPatterns.filter((pattern) => !lower.includes(pattern.toLowerCase()))); + } + + function addPattern() { + if (!policyDraft.trim()) return; + void savePolicy([...blockedPatterns, policyDraft]); + setPolicyDraft(""); + } + function updateRole(role: string, model: string) { setStatus("idle"); setRoleOverrides((current) => { @@ -253,7 +346,7 @@ export function EngagementModelPicker({ async function save() { if (choice === "custom" && !customModel.trim()) return; - if ([selectedModel, ...Object.values(cleanOverrides)].some(isBlockedModel)) { + if ([selectedModel, ...Object.values(cleanOverrides)].some((model) => isBlockedModel(model, blockedPatterns))) { setStatus("error"); return; } @@ -288,6 +381,7 @@ export function EngagementModelPicker({ Model { const nextChoice = @@ -320,6 +414,16 @@ export function EngagementModelPicker({ Agents +
+ {showPolicy && ( +
+
+ setPolicyDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") addPattern(); + }} + /> + +
+ +
+ {blockedPatterns.map((pattern) => ( + + ))} +
+ +
+ {selectableModels.map((model) => { + const blocked = isBlockedModel(model.id, blockedPatterns); + return ( +
+
+
{model.label}
+
{model.provider} · {model.id}
+
+ +
+ ); + })} +
+
+ )} + {showRoles && (
{AGENT_ROLES.map((role) => ( @@ -347,6 +522,7 @@ export function EngagementModelPicker({
updateRole(role.value, next)} /> diff --git a/clients/web/src/components/layout/header.tsx b/clients/web/src/components/layout/header.tsx index db654991f..50f7ff654 100644 --- a/clients/web/src/components/layout/header.tsx +++ b/clients/web/src/components/layout/header.tsx @@ -21,7 +21,12 @@ export function Header() { - Local Mode + + Self-hosted +
); diff --git a/clients/web/src/lib/model-options.ts b/clients/web/src/lib/model-options.ts new file mode 100644 index 000000000..c18bde4ce --- /dev/null +++ b/clients/web/src/lib/model-options.ts @@ -0,0 +1,52 @@ +export type BuiltinModelGroup = "default" | "gpt" | "mittwald" | "cloud"; + +export type BuiltinModelOption = { + value: string; + label: string; + group: BuiltinModelGroup; +}; + +export const DEFAULT_MODEL_OPTION: BuiltinModelOption = { + value: "default", + label: "Default chain", + group: "default", +}; + +export const GPT_MODELS: BuiltinModelOption[] = [ + { value: "auth/gpt-5.5", label: "GPT OAuth 5.5", group: "gpt" }, + { value: "auth/gpt-5.4", label: "GPT OAuth 5.4", group: "gpt" }, + { value: "auth/gpt-5.4-mini", label: "GPT OAuth 5.4 Mini", group: "gpt" }, + { value: "auth/gpt-5.3-codex", label: "GPT OAuth 5.3 Codex", group: "gpt" }, + { value: "auth/gpt-5.3-codex-spark", label: "GPT OAuth 5.3 Codex Spark", group: "gpt" }, +]; + +export const MITTWALD_MODELS: BuiltinModelOption[] = [ + { value: "mittwald/gpt-oss-120b", label: "gpt-oss-120b", group: "mittwald" }, + { value: "mittwald/Ministral-3-14B-Instruct-2512", label: "Ministral-3-14B-Instruct-2512", group: "mittwald" }, + { value: "mittwald/whisper-large-v3-turbo", label: "whisper-large-v3-turbo", group: "mittwald" }, + { value: "mittwald/Qwen3.5-122B-A10B-FP8", label: "Qwen3.5-122B-A10B-FP8", group: "mittwald" }, + { value: "mittwald/Qwen3.6-35B-A3B-FP8", label: "Qwen3.6-35B-A3B-FP8", group: "mittwald" }, + { value: "mittwald/Qwen3.5-0.8B", label: "Qwen3.5-0.8B", group: "mittwald" }, + { value: "mittwald/Qwen3-VL-Reranker-2B", label: "Qwen3-VL-Reranker-2B", group: "mittwald" }, + { value: "mittwald/GLM-OCR", label: "GLM-OCR", group: "mittwald" }, + { value: "mittwald/Mistral-Medium-3.5-128B", label: "Mistral-Medium-3.5-128B", group: "mittwald" }, + { value: "mittwald/Qwen3-Embedding-8B", label: "Qwen3-Embedding-8B", group: "mittwald" }, +]; + +export const CLOUD_MODELS: BuiltinModelOption[] = [ + { value: "openrouter/moonshotai/kimi-k2", label: "Kimi K2 via OpenRouter", group: "cloud" }, + { value: "groq/llama-3.1-8b-instant", label: "Groq Free", group: "cloud" }, +]; + +export const BUILTIN_MODEL_GROUPS = [ + { label: "GPT", models: GPT_MODELS }, + { label: "Mittwald", models: MITTWALD_MODELS }, + { label: "Cloud", models: CLOUD_MODELS }, +] as const; + +export const BUILTIN_MODELS: BuiltinModelOption[] = [ + DEFAULT_MODEL_OPTION, + ...GPT_MODELS, + ...MITTWALD_MODELS, + ...CLOUD_MODELS, +]; diff --git a/clients/web/src/lib/model-policy.ts b/clients/web/src/lib/model-policy.ts new file mode 100644 index 000000000..21229d5ed --- /dev/null +++ b/clients/web/src/lib/model-policy.ts @@ -0,0 +1,85 @@ +import * as fs from "fs/promises"; +import * as path from "path"; + +export const DEFAULT_BLOCKED_MODEL_PATTERNS = ["wormgpt", "uncensored", "abliterate"] as const; + +export type ModelPolicy = { + blockedPatterns: string[]; +}; + +const MAX_PATTERN_LENGTH = 200; + +function policyPath(): string { + return ( + process.env.MODEL_POLICY_PATH ?? + path.join(process.env.WORKSPACE_PATH ?? "/workspace", ".decepticon", "model-policy.json") + ); +} + +function normalizePattern(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed || trimmed.length > MAX_PATTERN_LENGTH) return null; + if (/[\x00-\x1f\x7f]/.test(trimmed)) return null; + return trimmed; +} + +function uniquePatterns(values: unknown[]): string[] { + const seen = new Set(); + const patterns: string[] = []; + for (const value of values) { + const pattern = normalizePattern(value); + if (!pattern) continue; + const key = pattern.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + patterns.push(pattern); + } + return patterns; +} + +export function modelIdMatchesPattern(modelId: string, pattern: string): boolean { + return modelId.toLowerCase().includes(pattern.toLowerCase()); +} + +export function isBlockedModelId(modelId: string, patterns: string[]): boolean { + return patterns.some((pattern) => modelIdMatchesPattern(modelId, pattern)); +} + +export async function readModelPolicy(): Promise { + try { + const raw = await fs.readFile(policyPath(), "utf8"); + const parsed = JSON.parse(raw) as { blockedPatterns?: unknown }; + const values = Array.isArray(parsed.blockedPatterns) ? parsed.blockedPatterns : []; + return { + blockedPatterns: uniquePatterns(values), + }; + } catch { + return { + blockedPatterns: [...DEFAULT_BLOCKED_MODEL_PATTERNS], + }; + } +} + +export async function writeModelPolicy(blockedPatterns: unknown[]): Promise { + const clean = uniquePatterns(blockedPatterns); + const target = policyPath(); + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.writeFile( + target, + `${JSON.stringify({ blockedPatterns: clean }, null, 2)}\n`, + "utf8", + ); + return { + blockedPatterns: clean, + }; +} + +export async function assertModelAllowed(modelId: string): Promise { + const trimmed = modelId.trim(); + if (!trimmed) return; + const policy = await readModelPolicy(); + if (isBlockedModelId(trimmed, policy.blockedPatterns)) { + throw new Error("Blocked model id is not allowed for this application"); + } +} From 6637a402b11380ffaea611330a0efccaa6840c89 Mon Sep 17 00:00:00 2001 From: "CONNSKILL GmbH & Co. KG" Date: Wed, 8 Jul 2026 21:11:12 +0200 Subject: [PATCH 10/10] Refine live dashboard workspace layout --- clients/cli/src/components/Banner.tsx | 6 +- clients/launcher/internal/ui/theme.go | 4 +- clients/web/server/terminal-server.ts | 2 +- .../(dashboard)/engagements/[id]/layout.tsx | 45 +++--- clients/web/src/app/(dashboard)/layout.tsx | 4 +- .../engagement/engagement-model-picker.tsx | 136 +++++++++++------- clients/web/src/components/layout/sidebar.tsx | 76 ++++++++-- .../src/components/terminal/web-terminal.tsx | 2 +- 8 files changed, 182 insertions(+), 93 deletions(-) diff --git a/clients/cli/src/components/Banner.tsx b/clients/cli/src/components/Banner.tsx index cf27a5e53..ff11046c8 100644 --- a/clients/cli/src/components/Banner.tsx +++ b/clients/cli/src/components/Banner.tsx @@ -47,6 +47,8 @@ const BANNER_LOGO = extractLogo(BANNER_FULL); const BANNER_LOGO_WIDTH = Math.max( ...BANNER_LOGO.split("\n").map((l) => l.length), ); +const COMPACT_FULL_WIDTH = Math.max(BANNER_FULL_WIDTH, 220); +const COMPACT_LOGO_WIDTH = Math.max(BANNER_LOGO_WIDTH, 110); // ── Responsive banner component ────────────────────────────────────── @@ -55,12 +57,12 @@ export const Banner = React.memo(function Banner() { const cols = stdout?.columns ?? 80; // Wide terminal — full banner with logo + text art - if (cols >= BANNER_FULL_WIDTH) { + if (cols >= COMPACT_FULL_WIDTH) { return {BANNER_FULL}; } // Medium terminal — braille logo + text name below - if (cols >= BANNER_LOGO_WIDTH) { + if (cols >= COMPACT_LOGO_WIDTH) { return ( {BANNER_LOGO} diff --git a/clients/launcher/internal/ui/theme.go b/clients/launcher/internal/ui/theme.go index 548e2e032..51033fa35 100644 --- a/clients/launcher/internal/ui/theme.go +++ b/clients/launcher/internal/ui/theme.go @@ -76,8 +76,8 @@ const bannerLogo = "" + "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⢷⡿⡯⠃\n" + "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠁" -const bannerFullWidth = 165 -const bannerLogoWidth = 60 +const bannerFullWidth = 220 +const bannerLogoWidth = 110 // RenderBanner returns a responsive banner based on terminal width. func RenderBanner() string { diff --git a/clients/web/server/terminal-server.ts b/clients/web/server/terminal-server.ts index eb345db1f..8446a96b3 100755 --- a/clients/web/server/terminal-server.ts +++ b/clients/web/server/terminal-server.ts @@ -290,7 +290,7 @@ wss.on("connection", async (ws: WebSocket, req) => { try { term = pty.spawn("node", ["--import", "tsx/esm", CLI_PATH], { name: "xterm-256color", - cols: 120, + cols: 80, rows: 30, cwd: resolve(__dirname, "../.."), env, diff --git a/clients/web/src/app/(dashboard)/engagements/[id]/layout.tsx b/clients/web/src/app/(dashboard)/engagements/[id]/layout.tsx index fd8275575..d9e95f474 100644 --- a/clients/web/src/app/(dashboard)/engagements/[id]/layout.tsx +++ b/clients/web/src/app/(dashboard)/engagements/[id]/layout.tsx @@ -5,7 +5,7 @@ import { useParams, usePathname } from "next/navigation"; import { EngagementProvider } from "@/lib/engagement-context"; import { useRunObserver } from "@/hooks/useRunObserver"; import { WebTerminal } from "@/components/terminal/web-terminal"; -import { EngagementModelPicker, type ModelOverrides } from "@/components/engagement/engagement-model-picker"; +import type { ModelOverrides } from "@/components/engagement/engagement-model-picker"; import { cn } from "@/lib/utils"; const REQUIRED_PLAN_DOCS = ["roe", "conops", "deconfliction"] as const; @@ -65,6 +65,29 @@ export default function EngagementLayout({ return () => { cancelled = true; }; }, [engagementId]); + useEffect(() => { + function handleModelsUpdated(event: Event) { + const detail = (event as CustomEvent<{ + engagementId?: string; + modelOverride?: string | null; + modelOverrides?: ModelOverrides | null; + }>).detail; + if (detail?.engagementId !== engagementId) return; + setEngagement((current) => + current + ? { + ...current, + modelOverride: detail.modelOverride || null, + modelOverrides: detail.modelOverrides ?? null, + } + : current, + ); + } + + window.addEventListener("decepticon:engagement-models-updated", handleModelsUpdated); + return () => window.removeEventListener("decepticon:engagement-models-updated", handleModelsUpdated); + }, [engagementId]); + // Persistent observer — survives tab navigation const { events, isRunning, activeRunId } = useRunObserver({ threadId }); @@ -85,26 +108,6 @@ export default function EngagementLayout({ activeRunId={activeRunId} >
-
- {engagement && ( - - setEngagement((current) => - current - ? { - ...current, - modelOverride: modelOverride || null, - modelOverrides, - } - : current, - ) - } - /> - )} -
-
-
{children}
+
{children}
diff --git a/clients/web/src/components/engagement/engagement-model-picker.tsx b/clients/web/src/components/engagement/engagement-model-picker.tsx index c7d5a7469..778583c38 100644 --- a/clients/web/src/components/engagement/engagement-model-picker.tsx +++ b/clients/web/src/components/engagement/engagement-model-picker.tsx @@ -13,6 +13,7 @@ import { SelectValue, } from "@/components/ui/select"; import { BUILTIN_MODEL_GROUPS, BUILTIN_MODELS, DEFAULT_MODEL_OPTION } from "@/lib/model-options"; +import { cn } from "@/lib/utils"; import { Ban, Check, RefreshCw, Save, Shield, SlidersHorizontal, X } from "lucide-react"; type LocalModel = { @@ -105,11 +106,13 @@ function RoleModelSelect({ value, blockedPatterns, localModelsByProvider, + compact = false, onChange, }: { value: string; blockedPatterns: string[]; localModelsByProvider: Partial>; + compact?: boolean; onChange: (value: string) => void; }) { const [forceCustom, setForceCustom] = useState(false); @@ -123,7 +126,7 @@ function RoleModelSelect({ }, [knownChoice, trimmedValue]); return ( -
+
{choice === "custom" && ( onChange(event.target.value)} @@ -195,11 +198,13 @@ export function EngagementModelPicker({ engagementId, value, overrides, + variant = "bar", onChange, }: { engagementId: string; value: string; overrides?: ModelOverrides | null; + variant?: "bar" | "sidebar"; onChange: (value: string, overrides: ModelOverrides) => void; }) { const [choice, setChoice] = useState(choiceFromOverride(value)); @@ -366,7 +371,18 @@ export function EngagementModelPicker({ modelOverride?: string | null; modelOverrides?: ModelOverrides | null; }; - onChange(engagement.modelOverride ?? "", normalizeOverrides(engagement.modelOverrides)); + const nextModel = engagement.modelOverride ?? ""; + const nextOverrides = normalizeOverrides(engagement.modelOverrides); + onChange(nextModel, nextOverrides); + window.dispatchEvent( + new CustomEvent("decepticon:engagement-models-updated", { + detail: { + engagementId, + modelOverride: nextModel, + modelOverrides: nextOverrides, + }, + }), + ); setStatus("saved"); } catch { setStatus("error"); @@ -375,14 +391,23 @@ export function EngagementModelPicker({ } } + const compact = variant === "sidebar"; + return ( -
-
+
+
Model { const nextChoice = next && isKnownModelChoice(next, localModelsByProvider) @@ -393,56 +418,58 @@ export function EngagementModelPicker({ setStatus("idle"); }} /> - - - - +
+ + + + +
{status === "saved" && Saved} {status === "error" && Error}
{showPolicy && ( -
-
+
+
setPolicyDraft(event.target.value)} @@ -484,7 +511,7 @@ export function EngagementModelPicker({ return (
{model.label}
@@ -510,13 +537,13 @@ export function EngagementModelPicker({ )} {showRoles && ( -
+
{AGENT_ROLES.map((role) => (
-
+
{role.label}
{role.hint}
@@ -524,6 +551,7 @@ export function EngagementModelPicker({ value={roleOverrides[role.value] ?? ""} blockedPatterns={blockedPatterns} localModelsByProvider={localModelsByProvider} + compact={compact} onChange={(next) => updateRole(role.value, next)} />
diff --git a/clients/web/src/components/layout/sidebar.tsx b/clients/web/src/components/layout/sidebar.tsx index 530a16327..6ec9a79c5 100644 --- a/clients/web/src/components/layout/sidebar.tsx +++ b/clients/web/src/components/layout/sidebar.tsx @@ -17,9 +17,11 @@ import { ChevronLeft, ChevronRight, ChevronDown, + Bell, } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; +import { EngagementModelPicker, type ModelOverrides } from "@/components/engagement/engagement-model-picker"; import { Tooltip, TooltipContent, @@ -56,6 +58,8 @@ const bottomNav: NavItem[] = [ interface Engagement { id: string; name: string; + modelOverride?: string | null; + modelOverrides?: ModelOverrides | null; } export function Sidebar() { @@ -63,6 +67,7 @@ export function Sidebar() { const [collapsed, setCollapsed] = useState(false); const [engDropdownOpen, setEngDropdownOpen] = useState(false); const [engagements, setEngagements] = useState([]); + const [activeEngagement, setActiveEngagement] = useState(null); // Derive engagement ID from pathname — only refetch when engagement context changes const engMatch = pathname.match(/^\/engagements\/([^/]+)/); @@ -84,8 +89,29 @@ export function Sidebar() { return () => { cancelled = true; }; }, [activeEngId]); + useEffect(() => { + if (!activeEngId) { + setActiveEngagement(null); + return; + } + + let cancelled = false; + fetch(`/api/engagements/${activeEngId}`, { cache: "no-store" }) + .then((res) => { + if (!res.ok) throw new Error("fetch failed"); + return res.json(); + }) + .then((data: Engagement) => { + if (!cancelled) setActiveEngagement(data); + }) + .catch(() => { + if (!cancelled) setActiveEngagement(null); + }); + return () => { cancelled = true; }; + }, [activeEngId]); + const activeEng = activeEngId - ? engagements.find((e) => e.id === activeEngId) ?? null + ? activeEngagement ?? engagements.find((e) => e.id === activeEngId) ?? null : null; function resolveHref(item: NavItem) { @@ -173,21 +199,31 @@ export function Sidebar() {