diff --git a/frontends/conductor.html b/frontends/conductor.html
index 9959b75a..8903195f 100644
--- a/frontends/conductor.html
+++ b/frontends/conductor.html
@@ -540,7 +540,12 @@
messagesEl.innerHTML=''; (data.chat||[]).forEach(addMsg);
renderLog(data.log||[]);
llmEl.innerHTML=(data.llms||[]).map(x=>``).join(''); llmEl.value=data.llm; llmEl.disabled=!llmEl.options.length;
- } else if(data.type==='subagents'){ renderCards(data.items||[]); }
+ } else if(data.type==='model_selected'){ llmEl.value=String(data.llm); }
+ else if(data.type==='model_error'){
+ if(data.llm!==undefined) llmEl.value=String(data.llm);
+ showLimitToast(data.error||'Failed to select model');
+ }
+ else if(data.type==='subagents'){ renderCards(data.items||[]); }
else if(data.type==='chat'){ addMsg(data.item); }
else if(data.type==='chat_read'){ markAllRead(); }
else if(data.type==='log'){ appendLog(data.item); }
diff --git a/frontends/conductor.py b/frontends/conductor.py
index cbd205ac..ad7817d4 100644
--- a/frontends/conductor.py
+++ b/frontends/conductor.py
@@ -1,5 +1,6 @@
import os, sys, re, time, json, uuid, queue, asyncio, threading, argparse, base64, secrets
from dataclasses import dataclass, field
+from pathlib import Path
from typing import Dict, Any, Optional, List
from contextlib import asynccontextmanager, suppress
@@ -8,6 +9,10 @@ from fastapi.responses import FileResponse, PlainTextResponse, JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
+FRONTENDS_DIR = os.path.dirname(os.path.abspath(__file__))
+if FRONTENDS_DIR not in sys.path: sys.path.insert(0, FRONTENDS_DIR)
+from desktop_settings import DesktopSettingsError, update_settings
+
def _resolve_ga_root() -> str:
"""Use the external core selected by the package-owned desktop bridge when valid."""
value = (os.environ.get("GA_ROOT", "") or "").strip()
@@ -67,15 +72,28 @@ HOST = args.host
PORT = args.port
+SETTINGS_PATH = Path.home() / ".ga_desktop_settings.json"
+
+
def _settings_doc() -> dict:
try:
- from pathlib import Path
- doc = json.loads((Path.home() / ".ga_desktop_settings.json").read_text(encoding="utf-8"))
+ doc = json.loads(SETTINGS_PATH.read_text(encoding="utf-8"))
return doc if isinstance(doc, dict) else {}
except Exception:
return {}
+def _persist_conductor_llm_no(llm_no: int) -> None:
+ def mutate(document: dict) -> None:
+ section = document.get("conductor")
+ if not isinstance(section, dict):
+ section = {}
+ document["conductor"] = section
+ section["llmNo"] = llm_no
+
+ update_settings(SETTINGS_PATH, mutate)
+
+
def _conductor_llm_no() -> Optional[int]:
"""Read the model index bound to the conductor session.
Falls back to the legacy desktop default ui.llmNo for existing installs."""
@@ -180,6 +198,24 @@ def _apply_desktop_model(agent: "GenericAgent") -> dict:
"fallbackReason": "no_models", "current": None}
+def _selected_conductor_llm_no(agent: "GenericAgent") -> int:
+ configured = _conductor_llm_no()
+ return configured if _usable_model(agent, configured) else agent.llm_no
+
+
+def _set_conductor_llm_no(agent: "GenericAgent", value: Any) -> int:
+ """Persist the standalone UI binding without mutating an in-flight client.
+
+ The Conductor loop applies this binding at its existing idle task boundary, so
+ a selection made immediately before sending a message controls that next turn.
+ """
+ no = _parse_model_no(value)
+ if no is None or not _usable_model(agent, no):
+ raise ValueError(f"llm index out of range or unavailable: {value}")
+ _persist_conductor_llm_no(no)
+ return no
+
+
def _select_llm(agent: "GenericAgent", llm: Any) -> bool:
if llm is None or str(llm).strip() == "": return False
q = str(llm).strip()
@@ -800,9 +836,22 @@ async def websocket(ws: WebSocket):
await ws.send_json({"type": "hello", "subagents": pool.snapshot(), "chat": chat_messages,
"log": conductor.log, "running": running,
"model": conductor.model_snapshot(),
- "llms": conductor.agent.list_llms(), "llm": conductor.agent.llm_no})
+ "llms": conductor.agent.list_llms(),
+ "llm": _selected_conductor_llm_no(conductor.agent)})
while True:
data = await ws.receive_json()
+ if "llm" in data:
+ try:
+ llm_no = _set_conductor_llm_no(conductor.agent, data["llm"])
+ except (TypeError, ValueError, OSError, DesktopSettingsError) as error:
+ await ws.send_json({
+ "type": "model_error",
+ "error": str(error),
+ "llm": _selected_conductor_llm_no(conductor.agent),
+ })
+ else:
+ await broadcast({"type": "model_selected", "llm": llm_no})
+ continue
msg = (data.get("msg") or "").strip()
if not msg: continue
add_chat(msg, role="user", files=data.get("files") or [], images=data.get("images") or [])
diff --git a/frontends/tests/test_conductor_model_routing.py b/frontends/tests/test_conductor_model_routing.py
index e0fa335b..3daf9e34 100644
--- a/frontends/tests/test_conductor_model_routing.py
+++ b/frontends/tests/test_conductor_model_routing.py
@@ -164,6 +164,50 @@ def test_explicit_numeric_worker_model_rejects_out_of_range():
assert agent.next_llm_calls == []
+def test_standalone_model_selection_persists_without_mutating_live_client(tmp_path, monkeypatch):
+ settings_path = tmp_path / "settings.json"
+ settings_path.write_text('{"theme":"dark","ui":{"llmNo":0}}', encoding="utf-8")
+ monkeypatch.setattr(conductor, "SETTINGS_PATH", settings_path)
+ agent = FakeAgent(["zero", "one", "two"])
+
+ selected = conductor._set_conductor_llm_no(agent, 2)
+
+ assert selected == 2
+ assert agent.llm_no == 0
+ assert agent.next_llm_calls == []
+ assert conductor._settings_doc() == {
+ "theme": "dark",
+ "ui": {"llmNo": 0},
+ "conductor": {"llmNo": 2},
+ }
+
+
+def test_standalone_model_selection_rejects_unavailable_model(tmp_path, monkeypatch):
+ settings_path = tmp_path / "settings.json"
+ settings_path.write_text('{"theme":"dark"}', encoding="utf-8")
+ monkeypatch.setattr(conductor, "SETTINGS_PATH", settings_path)
+ agent = FakeAgent(["zero", None])
+
+ with pytest.raises(ValueError, match="unavailable"):
+ conductor._set_conductor_llm_no(agent, 1)
+
+ assert conductor._settings_doc() == {"theme": "dark"}
+
+
+def test_standalone_model_selection_applies_at_next_task_boundary(tmp_path, monkeypatch):
+ settings_path = tmp_path / "settings.json"
+ monkeypatch.setattr(conductor, "SETTINGS_PATH", settings_path)
+ agent = FakeAgent(["zero", "one", "two"])
+
+ conductor._set_conductor_llm_no(agent, 2)
+ state = conductor._apply_desktop_model(agent)
+
+ assert agent.next_llm_calls == [2]
+ assert state["configured"] == 2
+ assert state["effective"] == 2
+ assert state["fallbackReason"] is None
+
+
def test_runtime_model_snapshot_is_broadcast_with_running_state(monkeypatch):
instance = conductor.Conductor()
payloads: list[dict] = []