diff --git a/src/decompnet/app/routes/setup.py b/src/decompnet/app/routes/setup.py index a00ad4c3d..b991932df 100644 --- a/src/decompnet/app/routes/setup.py +++ b/src/decompnet/app/routes/setup.py @@ -7,6 +7,7 @@ import shutil import subprocess +import httpx from starlette.requests import Request from starlette.responses import JSONResponse, Response @@ -78,6 +79,29 @@ async def api_agents_detect(request: Request) -> Response: manager.refresh_detected() return JSONResponse({"detecting": True}) + @mcp.custom_route("/api/endpoints/models", methods=["GET"]) + async def api_endpoint_models(request: Request) -> Response: + """List models from one saved endpoint, using its own key and never returning connection details.""" + if (bad := guard(request)): + return bad + entry = settings.endpoint(request.query_params.get("id", "")) + if entry is None: + return fail(ValueError("that API endpoint is no longer saved; choose it again")) + headers = {"Authorization": f"Bearer {entry['api_key']}"} if entry.get("api_key") else {} + try: + response = await call(lambda: httpx.get(entry["base_url"].rstrip("/") + "/models", headers=headers, + timeout=10)) + response.raise_for_status() + payload = response.json() + rows = payload.get("data", []) if isinstance(payload, dict) else [] + if not isinstance(rows, list): + rows = [] + models = [{"id": str(row["id"]), "name": str(row.get("name") or row["id"])} + for row in rows if isinstance(row, dict) and row.get("id")] + return JSONResponse({"models": models}) + except (httpx.HTTPError, httpx.InvalidURL, ValueError, TypeError): + return JSONResponse({"models": [], "unavailable": True}) + @mcp.custom_route("/api/agents/start", methods=["POST"]) async def api_agents_start(request: Request) -> Response: if (bad := guard(request, write=True)): @@ -85,9 +109,29 @@ async def api_agents_start(request: Request) -> Response: data = await body(request) kind, slug = str(data.get("kind", "")), str(data.get("slug", "")) given = data.get("options") or {} - options = {k: v for k, v in given.items() if k in ("model", "per_session", "task")} # task: "readable" polishes + requested_models = given.get("models", []) + if not isinstance(requested_models, list) or any( + not isinstance(model, (str, dict)) or + (not model if isinstance(model, str) else + set(model) != {"endpoint", "model"} or + any(not isinstance(model.get(key), str) or not model[key] for key in ("endpoint", "model"))) + for model in requested_models): + return fail(ValueError("models must be a list of model names")) + seen_models = set() + unique_models = [] + for model in requested_models: + key = (model["endpoint"], model["model"]) if isinstance(model, dict) else model + if key not in seen_models: + seen_models.add(key) + unique_models.append(model) + requested_models = unique_models + if given.get("multiple_models") and not requested_models: + return fail(ValueError("choose at least one model")) + options = {k: v for k, v in given.items() if k in ("model", "per_session", "task", "endpoints")} # task: "readable" polishes options.setdefault("per_session", settings.data.get("per_session", agents.PER_SESSION)) count = max(1, min(int(data.get("count", 1)), agents.MAX_AT_ONCE)) + if requested_models and options.get("model"): + return fail(ValueError("choose specific models or one model, not both")) picked: list[str] = [] try: net.project_row(slug) @@ -95,17 +139,62 @@ async def api_agents_start(request: Request) -> Response: if given.get("effort") in kinds.get(kind).efforts: # an endpoint helper's (the others' rides on the model) options["effort"] = given["effort"] if kinds.get(kind).uses_endpoints: - picked = [str(e) for e in (given.get("endpoints") or ([given["endpoint"]] if given.get("endpoint") else []))] - plan = spread_sessions(settings, manager, picked, count, given) - ids = [sid for entry, n in plan for sid in manager.start(kind, slug, n, options | session_options(entry))] + if requested_models: + if any(not isinstance(model, dict) or not model.get("endpoint") for model in requested_models): + raise ValueError("choose API models from a saved endpoint") + choices = [(str(model["endpoint"]), str(model["model"])) for model in requested_models] + entries = {eid: settings.endpoint(eid) for eid, _ in choices} + if any(entry is None for entry in entries.values()): + raise ValueError("an endpoint you picked isn't saved any more: pick again") + per_endpoint = {eid: sum(1 for chosen, _ in choices if chosen == eid) * count for eid in entries} + for eid, needed in per_endpoint.items(): + entry = entries[eid] + active = sum(1 for sess in manager.sessions.values() if sess.options.get("endpoint") == eid + and sess.state in agents.LIVE and not sess.stop.is_set()) + if entry.get("max_sessions") and active + needed > int(entry["max_sessions"]): + raise ValueError(f"{entry['name']} allows {entry['max_sessions']} sessions at once; reduce the selected models or stop a session") + ids = [] + for eid, model in choices: + entry = entries[eid] + ids.extend(manager.start(kind, slug, count, options | session_options(entry) | {"model": model})) + picked = list(dict.fromkeys(eid for eid, _ in choices)) + else: + picked = [str(e) for e in (given.get("endpoints") or ([given["endpoint"]] if given.get("endpoint") else []))] + plan = spread_sessions(settings, manager, picked, count, given) + ids = [] + for entry, n in plan: + entry_options = options | session_options(entry) + if given.get("model"): + entry_options["model"] = str(given["model"]) + ids.extend(manager.start(kind, slug, n, entry_options)) + elif requested_models: + if any(not isinstance(model, str) for model in requested_models): + raise ValueError("API endpoint/model selections are only valid for API agents") + found = next((row for row in manager.detected() if row["id"] == kind), None) + model_ids = {str(model["id"]) for model in (found or {}).get("models", []) + if isinstance(model, dict) and model.get("id")} + missing = [model for model in requested_models if model not in model_ids] + if missing and model_ids: + raise ValueError("some selected models are no longer available; look again for installed agents and their models") + ids = [] + for model in requested_models: + if isinstance(model, dict): + raise ValueError("API model selections are only valid for API agents") + if options.get("effort"): + model = f"{model.split('#')[0]}#{options['effort']}" + ids.extend(manager.start(kind, slug, count, options | {"model": model})) else: ids = manager.start(kind, slug, count, options) except (NetworkError, ValueError) as e: return fail(e) # remembered, so the next start is one click; models typed in come back as suggestions model = str(options.get("model") or "") - settings.data["launcher"] = {"kind": kind, "model": model, "count": count} | ({"endpoints": picked} if picked else {}) - if model and not kinds.get(kind).uses_endpoints: + settings.data["launcher"] = {"kind": kind, "model": model, "models": requested_models, + "multiple": bool(given.get("multiple_models")), "count": count} | ( + {"endpoints": picked} if picked else {}) + if kinds.get(kind).uses_endpoints and model and picked: + settings.data["launcher"]["api_model"] = {"endpoint": picked[0], "model": model} + if not requested_models and model and not kinds.get(kind).uses_endpoints: recent = settings.data.setdefault("recent_models", {}).setdefault(kind, []) settings.data["recent_models"][kind] = [model] + [m for m in recent if m != model][:7] settings.save() diff --git a/src/decompnet/app/static/app.css b/src/decompnet/app/static/app.css index 8ad435a17..f6829c7cc 100644 --- a/src/decompnet/app/static/app.css +++ b/src/decompnet/app/static/app.css @@ -431,6 +431,7 @@ a.ghost, a.primary { text-decoration: none; } .lz-body { display: grid; gap: 12px; } .lz-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } .lz-fields .wide { grid-column: 1 / -1; } +.lz-models { display: grid; gap: 6px; max-height: 190px; overflow: auto; padding: 9px 10px; border: 1px solid var(--line); border-radius: var(--r); } .ep-pick { display: grid; gap: 8px; } .ep-card { display: grid; gap: 12px; padding: 14px; border: 1px solid var(--line); border-radius: var(--r); background: var(--raise); margin: 0 0 12px; } .lz-row { display: flex; align-items: flex-end; gap: 12px; flex-wrap: wrap; } diff --git a/src/decompnet/app/static/js/agents.js b/src/decompnet/app/static/js/agents.js index 5b70f6b28..164e50ae7 100644 --- a/src/decompnet/app/static/js/agents.js +++ b/src/decompnet/app/static/js/agents.js @@ -43,6 +43,7 @@ const FEED_KEEP = 400; // lines of each helper's log kept here (the app keeps a const ACTIVITY_ROWS = 300; // lines "What's happening" keeps: older ones scroll away const SAID_SHOWN = 1600, THOUGHT_SHOWN = 1200; // characters of a helper's message and thought its log shows const MAX_PER_SESSION = 20; // functions a helper takes in one session, at most +const MAX_AT_ONCE = 8; const hhmm = (t) => clock(t).slice(0, 5); const sentence = (text) => (text ? text[0].toUpperCase() + text.slice(1) : ""); @@ -128,7 +129,7 @@ $("stopAll").onclick = async () => { }; /* ------------------------------------------------------------------ the launcher: agent, model, how many */ -const LZ = { kind: null, model: "", count: 1, per: null, effort: "", task: "" }; +const LZ = { kind: null, model: "", customModel: false, apiSingleModel: null, apiCustomModel: "", models: [], multiple: false, endpointModels: {}, byKind: {}, count: 1, per: null, effort: "", task: "" }; const EFFORT_NAMES = { minimal: "Minimal", low: "Low", medium: "Medium", high: "High", xhigh: "Extra high", max: "Max" }; const effortField = (a) => (a.takes_effort ? `` : ""); // an agent that takes an effort (opencode 2, Claude Code) gets it with the model's name: provider/model#high. The @@ -143,7 +144,10 @@ function launcherInit() { // can break it) const first = S.st.agents.find((a) => a.recommended) || S.st.agents[0]; LZ.kind = known ? known.id : first ? first.id : null; - LZ.model = known ? (l.model || "") : ""; + LZ.model = known && l.model !== "__all_models" ? (l.model || "") : ""; + LZ.models = known && Array.isArray(l.models) ? l.models.map((m) => typeof m === "string" ? m : ({ endpoint: String(m.endpoint), model: String(m.model) })) : []; + LZ.multiple = !!(known && (l.multiple || l.model === "__all_models" || LZ.models.length)); + LZ.apiSingleModel = known && l.api_model ? { endpoint: String(l.api_model.endpoint), model: String(l.api_model.model) } : null; LZ.count = l.count || 1; const eps = S.st.settings.endpoints || []; LZ.eps = (l.endpoints || []).filter((id) => eps.some((e) => e.id === id)); @@ -152,16 +156,43 @@ function launcherInit() { function modelOptions(a) { const recent = (S.st.settings.recent_models || {})[a.id] || []; const opts = [{ id: "", name: a.default_model ? `Default · ${a.default_model}` : "Default", note: "" }]; - for (const m of a.models || []) opts.push(m); + for (const m of a.models || []) opts.push({ id: m.id, name: m.name, note: m.note || "" }); for (const r of recent) if (!opts.some((o) => o.id === r)) opts.push({ id: r, name: r, note: "used before" }); + opts.push({ id: "__multiple", name: "Multiple models", note: "" }); + opts.push({ id: "__other", name: "Another model…", note: "" }); return opts; } +function selectableModels(a) { + if (a.uses_endpoints) { + const eps = (S.st.settings.endpoints || []).filter((e) => (LZ.eps || []).includes(e.id)); + const listed = eps.flatMap((e) => (LZ.endpointModels[e.id] || []).map((m) => ({ + id: `${e.id}::${m.id}`, endpoint: e.id, model: m.id, name: `${e.name} · ${m.name}`, + }))); + for (const chosen of LZ.models) if (typeof chosen === "object" && eps.some((e) => e.id === chosen.endpoint) && + !listed.some((m) => m.endpoint === chosen.endpoint && m.model === chosen.model)) { + const endpoint = eps.find((e) => e.id === chosen.endpoint); + listed.push({ id: `${chosen.endpoint}::${chosen.model}`, endpoint: chosen.endpoint, model: chosen.model, + name: `${endpoint.name} · ${chosen.model}`, note: "typed model" }); + } + return listed; + } + const listed = [...(a.models || [])]; + if (!listed.length) for (const model of LZ.models) if (typeof model === "string" && !listed.some((m) => m.id === model)) { + listed.push({ id: model, name: model, note: "typed model" }); + } + return listed; +} function modelName(a, model) { if (!model) return ""; const m = (a.models || []).find((x) => x.id === model); return m ? m.name : model; } -function launchLabel(a, model, count) { +function launchLabel(a, model, count, models = []) { + if (models.length) { + const total = models.length * count; + const effort = effortOf(a) ? ` · ${EFFORT_NAMES[effortOf(a)] || effortOf(a)}` : ""; + return `Start ${models.length} ${models.length === 1 ? "model" : "models"}${effort} × ${count} = ${total} ${total === 1 ? "agent" : "agents"}`; + } if (a.uses_endpoints) { const picked = (S.st.settings.endpoints || []).filter((e) => (LZ.eps || []).includes(e.id)); const eff = effortOf(a) ? ` · ${EFFORT_NAMES[effortOf(a)] || effortOf(a)}` : ""; @@ -171,23 +202,28 @@ function launchLabel(a, model, count) { return `Start ${a.name}${model ? ` · ${modelName(a, model)}` : ""}${effortOf(a) && model ? ` · ${EFFORT_NAMES[effortOf(a)] || effortOf(a)}` : ""}${count > 1 ? ` × ${count}` : ""}`; } -async function startAgents(a, model, count, btn, extra = {}) { +async function startAgents(a, model, count, btn, extra = {}, models = []) { if (btn) btn.disabled = true; try { const options = { ...extra }; - if (model && !a.uses_endpoints) options.model = withEffort(a, model); - if (a.uses_endpoints && effortOf(a)) options.effort = effortOf(a); // its model is the endpoint's - else if (effortOf(a) && !model) throw new Error("pick a model to set its effort"); + if (models.length) options.models = models; + if (LZ.multiple) options.multiple_models = true; + else if (model && !a.uses_endpoints) options.model = withEffort(a, model); + if ((a.uses_endpoints || models.length) && effortOf(a)) options.effort = effortOf(a); + else if (effortOf(a) && !model && !models.length) throw new Error("pick a model to set its effort"); if (LZ.per) options.per_session = LZ.per; if (LZ.task) options.task = LZ.task; await api("/api/agents/start", { method: "POST", body: { kind: a.id, slug: S.slug, count, options } }); - S.st.settings.launcher = { kind: a.id, model: model || "", count }; + S.st.settings.launcher = { kind: a.id, model: model || "", models, multiple: LZ.multiple, count, + ...(a.uses_endpoints && LZ.apiSingleModel ? { api_model: LZ.apiSingleModel } : {}), + ...(a.uses_endpoints ? { endpoints: LZ.eps || [] } : {}) }; if (model && !a.uses_endpoints) { const rec = (S.st.settings.recent_models ||= {}); rec[a.id] = [model, ...(rec[a.id] || []).filter((m) => m !== model)].slice(0, 8); } hideLaunch(); - toast(`${esc(a.uses_endpoints ? "Your API model" : a.name)} ${count > 1 ? `× ${count} are` : "is"} starting. ${LZ.task === "readable" ? "Matched functions get real names, types and a comment, one by one" : "Watch the map fill in"}; Agents says what each one does.`, "match"); + const total = LZ.multiple ? models.length * count : count; + toast(`${esc(a.uses_endpoints ? "Your API model" : a.name)}: ${total} ${total === 1 ? "agent is" : "agents are"} starting. ${LZ.task === "readable" ? "Matched functions get real names, types and a comment, one by one" : "Watch the map fill in"}; Agents says what each one does.`, "match"); poll(false); loadNav(); } catch (e) { toast(esc(e.message), "err"); } finally { if (btn) btn.disabled = false; } @@ -219,18 +255,37 @@ function renderLauncher(box, compact = true) { const eps = S.st.settings.endpoints || []; const why = `
The app talks to the model itself: nothing else to install, and no other program's update can break it. Any OpenAI-compatible provider works (OpenAI, DeepSeek, OpenRouter) or a model on this computer; you pay your provider for what it uses.
`; LZ.eps = (LZ.eps || []).filter((id) => eps.some((e) => e.id === id)); - if (!LZ.eps.length && eps.length) LZ.eps = [eps[0].id]; + for (const e of eps) if (LZ.eps.includes(e.id) && !LZ.endpointModels[`loaded:${e.id}`] && !LZ.endpointModels[`loading:${e.id}`]) { + LZ.endpointModels[`loading:${e.id}`] = true; + api(`/api/endpoints/models?id=${encodeURIComponent(e.id)}`).then((result) => { + LZ.endpointModels[e.id] = result.models || []; + LZ.endpointModels[`loaded:${e.id}`] = true; + if (box.isConnected) renderLauncher(box, compact); + }).catch(() => { LZ.endpointModels[e.id] = []; LZ.endpointModels[`loaded:${e.id}`] = true; if (box.isConnected) renderLauncher(box, compact); }); + } body = eps.length && !LZ.adding ? `${eps.length > 1 ? "Tick several to spread the sessions across them. " : ""}Manage endpoints ·
` : `${a.uses_endpoints ? "A built-in agent loop drives the model through the decomp tools." : esc(a.signin)} Each agent takes a different function; a fresh session every few functions keeps its context small.
` : ""} `; wireCopies(box); const rerender = () => renderLauncher(box, compact); box.querySelectorAll("[data-task]").forEach((b) => { b.onclick = () => { LZ.task = b.dataset.task; rerender(); }; }); - box.querySelectorAll(".lz-kind").forEach((b) => { b.onclick = () => { LZ.kind = b.dataset.k; LZ.model = (S.st.settings.launcher || {}).kind === LZ.kind ? S.st.settings.launcher.model || "" : ""; rerender(); }; }); + box.querySelectorAll(".lz-kind").forEach((b) => { b.onclick = () => { + LZ.byKind[LZ.kind] = { model: LZ.model, customModel: LZ.customModel, apiSingleModel: LZ.apiSingleModel, + apiCustomModel: LZ.apiCustomModel, models: LZ.models, multiple: LZ.multiple, eps: LZ.eps }; + LZ.kind = b.dataset.k; + const cached = LZ.byKind[LZ.kind]; + if (cached) { + Object.assign(LZ, cached); + rerender(); + return; + } + const saved = S.st.settings.launcher || {}; + LZ.model = saved.kind === LZ.kind && saved.model !== "__all_models" ? saved.model || "" : ""; + LZ.customModel = false; + LZ.apiSingleModel = saved.kind === LZ.kind && saved.api_model ? { ...saved.api_model } : null; + LZ.models = saved.kind === LZ.kind && Array.isArray(saved.models) ? saved.models.map((m) => typeof m === "string" ? m : ({ endpoint: String(m.endpoint), model: String(m.model) })) : []; + LZ.multiple = saved.kind === LZ.kind && !!(saved.multiple || LZ.models.length); + rerender(); + }; }); const sel = box.querySelector('select[data-f="model"]'), custom = box.querySelector('[data-f="custom"]'); - const label = () => { const btn = box.querySelector('[data-act="start"]'); if (btn) btn.textContent = launchLabel(a, LZ.model, LZ.count); }; - if (sel) sel.onchange = () => { if (sel.value === "__other") { custom.hidden = false; custom.focus(); LZ.model = custom.value.trim(); } else { custom.hidden = true; LZ.model = sel.value; } label(); }; - if (custom) custom.oninput = () => { LZ.model = custom.value.trim(); label(); }; + const apiSel = box.querySelector('[data-f="api-model"]'); + const label = () => { + const btn = box.querySelector('[data-act="start"]'); + if (btn) { + btn.textContent = launchLabel(a, LZ.model, LZ.count, LZ.models); + btn.disabled = LZ.multiple && !LZ.models.length; + } + const limit = box.querySelector("[data-model-limit]"); + if (limit) limit.textContent = `Each selected model gets ${LZ.count} session${LZ.count === 1 ? "" : "s"}.`; + box.querySelectorAll("[data-model-choice]").forEach((c) => { c.disabled = !!LZ.model; }); + }; + label(); + if (sel) sel.onchange = () => { + LZ.multiple = sel.value === "__multiple"; + if (!LZ.multiple) LZ.models = []; + if (sel.value === "__other") { LZ.customModel = true; LZ.model = ""; } + else { LZ.customModel = false; LZ.model = LZ.multiple ? "" : sel.value; } + rerender(); + label(); + }; + if (custom) custom.oninput = () => { LZ.customModel = true; LZ.model = custom.value.trim(); label(); }; + if (apiSel) apiSel.onchange = () => { + LZ.multiple = apiSel.value === "__multiple"; + LZ.model = ""; + LZ.models = []; + LZ.apiCustomModel = ""; + if (apiSel.value === "__other") LZ.apiSingleModel = { endpoint: (LZ.eps || [])[0] || "", model: "" }; + else if (apiSel.value) { + const item = selectableModels(a).find((m) => m.id === apiSel.value); + LZ.apiSingleModel = item ? { endpoint: item.endpoint, model: item.model } : null; + } else LZ.apiSingleModel = null; + rerender(); + }; + const apiCustom = box.querySelector('[data-f="api-custom"]'); + if (apiCustom && !LZ.multiple) apiCustom.oninput = () => { + LZ.apiCustomModel = apiCustom.value.trim(); + if (LZ.apiSingleModel) LZ.apiSingleModel.model = LZ.apiCustomModel; + }; + box.querySelectorAll("[data-model-choice]").forEach((c) => { + c.onchange = () => { + LZ.models = [...box.querySelectorAll("[data-model-choice]:checked")].map((x) => { + const item = selectableModels(a).find((m) => m.id === x.dataset.modelChoice); + return a.uses_endpoints ? { endpoint: item.endpoint, model: item.model } : x.dataset.modelChoice; + }); + label(); + }; + }); const eff = box.querySelector('[data-f="effort"]'); if (eff) eff.onchange = () => { LZ.effort = eff.value; label(); }; - box.querySelectorAll("[data-step]").forEach((b) => { b.onclick = () => { LZ.count = Math.max(1, Math.min(8, LZ.count + Number(b.dataset.step))); box.querySelector(".stepper output").textContent = LZ.count; label(); }; }); + box.querySelectorAll("[data-step]").forEach((b) => { b.onclick = () => { LZ.count = Math.max(1, Math.min(MAX_AT_ONCE, LZ.count + Number(b.dataset.step))); box.querySelector(".stepper output").textContent = LZ.count; label(); }; }); const per = box.querySelector('[data-f="per"]'); if (per) per.onchange = () => { LZ.per = Math.max(1, Math.min(MAX_PER_SESSION, Number(per.value) || S.st.settings.per_session)); }; wireEndpointFields(box); - box.querySelectorAll("[data-ep]").forEach((c) => { c.onchange = () => { LZ.eps = [...box.querySelectorAll("[data-ep]:checked")].map((x) => x.dataset.ep); label(); }; }); + box.querySelectorAll("[data-ep]").forEach((c) => { c.onchange = () => { + LZ.eps = [...box.querySelectorAll("[data-ep]:checked")].map((x) => x.dataset.ep); + LZ.models = LZ.models.filter((m) => LZ.eps.includes(m.endpoint)); + if (LZ.apiSingleModel && !LZ.eps.includes(LZ.apiSingleModel.endpoint)) LZ.apiSingleModel = null; + rerender(); + }; }); + const addModel = box.querySelector('[data-act="add-api-model"]'); + if (addModel) addModel.onclick = () => { + const name = box.querySelector('[data-f="api-custom"]').value.trim(); + if (!name) return toast("Type an API model name first.", "err"); + for (const endpoint of LZ.eps || []) if (!LZ.models.some((m) => m.endpoint === endpoint && m.model === name)) LZ.models.push({ endpoint, model: name }); + const input = box.querySelector('[data-f="api-custom"]'); + if (input) input.value = ""; + rerender(); + }; + const addCliModel = box.querySelector('[data-act="add-cli-model"]'); + if (addCliModel) addCliModel.onclick = () => { + const input = box.querySelector('[data-f="manual-model"]'), model = input.value.trim(); + if (!model) return toast("Type a model name first.", "err"); + if (!LZ.models.includes(model)) LZ.models.push(model); + rerender(); + }; const addEp = box.querySelector('[data-act="add-ep"]'), pickEp = box.querySelector('[data-act="pick-ep"]'); if (addEp) addEp.onclick = () => { LZ.adding = true; rerender(); }; if (pickEp) pickEp.onclick = () => { LZ.adding = false; rerender(); }; @@ -296,9 +433,14 @@ function renderLauncher(box, compact = true) { LZ.adding = false; } if (!(LZ.eps || []).length) return toast("Tick an endpoint to start on.", "err"); + if (LZ.multiple) return startAgents(a, "", LZ.count, go, {}, LZ.models); + if (LZ.apiSingleModel) { + if (!LZ.apiSingleModel.model.trim()) return toast("Choose or type an API model name.", "err"); + return startAgents(a, "", LZ.count, go, { endpoints: [LZ.apiSingleModel.endpoint], model: LZ.apiSingleModel.model }); + } return startAgents(a, "", LZ.count, go, { endpoints: LZ.eps }); } - startAgents(a, LZ.model, LZ.count, go); + startAgents(a, LZ.model, LZ.count, go, {}, LZ.models); }; } @@ -587,4 +729,3 @@ function addThoughts(sessions, first) { } if (first) renderLog(); } - diff --git a/tests/gui_launcher.js b/tests/gui_launcher.js new file mode 100644 index 000000000..e5450503b --- /dev/null +++ b/tests/gui_launcher.js @@ -0,0 +1,211 @@ +// Exercise the launcher's model controls and events without a browser or an installed agent. +"use strict"; +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); + +class Element { + constructor(tag = "div", attrs = {}) { + this.tag = tag; + this.dataset = {}; + this.attributes = attrs; + for (const [key, value] of Object.entries(attrs)) { + if (key.startsWith("data-")) this.dataset[key.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = value; + else this[key] = value; + } + this.checked = Object.hasOwn(attrs, "checked"); + this.hidden = Object.hasOwn(attrs, "hidden"); + this.disabled = Object.hasOwn(attrs, "disabled"); + this.value = attrs.value || ""; + this.textContent = ""; + this.isConnected = true; + } + focus() {} + setAttribute(key, value) { this.attributes[key] = value; } + querySelectorAll(selector) { return []; } + querySelector() { return null; } +} + +class Box extends Element { + set innerHTML(html) { + this.html = html; + this.elements = []; + const tagPattern = /<(button|input|select|option|output|a|span)\b([^>]*)>/g; + for (const match of html.matchAll(tagPattern)) { + const attrs = {}; + for (const item of match[2].matchAll(/([\w-]+)(?:="([^"]*)")?/g)) attrs[item[1]] = item[2] ?? ""; + const element = new Element(match[1], attrs); + if (match[1] === "span") element.textContent = html.slice(match.index + match[0].length, html.indexOf("", match.index)); + this.elements.push(element); + } + for (const select of this.elements.filter((element) => element.tag === "select")) { + const start = html.indexOf(`