studio: agent window, a chat with a model that edits the scene
Deploy web Studio / deploy (push) Successful in 3m3s

✦ Agent in the Studio's top bar opens a floating chat window. The model
(any OpenAI-compatible endpoint with tool calling; default B4L's inference
server, https://inference.p0.contact/v1) gets the 15 MCP tools plus two page
tools (select_object, set_view) and the Studio's state (selection, level,
rotation) in its system prompt. Answers and reasoning stream in; tool calls
show as rows with arguments and results; object ids link to the object;
Stop ends a turn; Undo takes the agent's edits back. The window moves by
its title bar and resizes at its top-left corner.

- studio/agent.js: shared by both front ends (waits for studio.js's
  isokit:studio event); MCP over /mcp or isokit.mcp in the web Studio
- isokit studio: /api/agent/{config,models,chat} relay the calls on their
  own threads (ureq, chunked and flushed per read), so the key stays on the
  machine and the endpoint needs no CORS; settings from ISOKIT_LLM_URL /
  _KEY / _MODEL or agent.json in the user's config folder
- web Studio: calls the endpoint from the page with a key kept in the
  browser; build_web.py --agent-url names a same-site relay as default; the
  deploy builds with /llm/v1, which deploy/Caddyfile forwards to the
  inference server (its nginx refuses the CORS preflight)
- tools/mock_llm.py + tools/agent_checks.py: the agent window checks in both
  check_studio.py and check_web.py
This commit is contained in:
grabowski committed 2026-10-23 19:35:53 +07:00
1 parent c647f39059
commit 1a1b62d475
21 files changed
+1690 -25

No files matched your search

+160
View File
@@ -0,0 +1,160 @@
"""Agent window checks, shared by check_studio.py (isokit studio) and
check_web.py (web Studio). The page's agent must use the mock model
(tools/mock_llm.py, key test-key); `start_mock()` runs one."""
import json
import subprocess
import sys
import urllib.request
from pathlib import Path
MOCK_PORT = 7932
MOCK = f"http://127.0.0.1:{MOCK_PORT}"
def start_mock():
p = subprocess.Popen([sys.executable, str(Path(__file__).with_name("mock_llm.py")), str(MOCK_PORT)],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
p.stdout.readline() # the banner: listening
return p
def seen():
return json.loads(urllib.request.urlopen(MOCK + "/_requests").read())
def chats():
return [r for r in seen() if r.get("path") == "/v1/chat/completions"]
def run(pg, objects, shot, check, can_set_key):
"""objects(): the scene's objects now. can_set_key: the key is set in the
window (web), not by the Studio server's environment."""
pg.locator("#agent").click()
check(pg.locator("#agwin").is_visible(), "✦ Agent opens the agent window")
pg.wait_for_function("() => /mock-1/.test(document.getElementById('agwho').textContent)", timeout=8000)
check(pg.locator("#agwho .ag-on").count() == 1, "the window connects to the model: " + pg.locator("#agwho").inner_text())
check(pg.locator(".ag-sug").count() >= 3, "an empty chat offers suggestions")
shot("agent-empty.jpg")
# a turn with two tool calls, then an answer
n0 = len(objects())
pg.locator("#agtext").fill("Plant a tree at 6, 4 please")
pg.locator("#agtext").press("Enter")
pg.wait_for_selector(".ag-bot:not(.ag-live) .ag-text code.ag-obj", timeout=15000)
objs = objects()
new = [o for o in objs if o.get("name") == "Agent tree"]
check(len(objs) == n0 + 1 and len(new) == 1 and new[0]["x"] == 6 and new[0]["y"] == 4,
"the agent's add_object lands in the scene at (6, 4)")
tid = new[0]["id"] if new else "?"
check(pg.locator(".ag-tool.ag-ok").count() == 2, "both tool calls show as done")
check(pg.locator(f'.ag-tool code.ag-obj[data-obj="{tid}"]').count() == 1, "the add_object row links the new object")
check(pg.locator(f'#objects li[data-id="{tid}"]').count() == 1, "the object list shows the agent's tree at once")
check(pg.locator(f'.iso-svg g[data-obj="{tid}"]').count() >= 1, "the map draws the agent's tree")
check(pg.locator(".ag-think summary").first.inner_text().startswith("Thought for"), "the reasoning folds away: "
+ pg.locator(".ag-think summary").first.inner_text())
txt = pg.locator(".ag-bot .ag-text").last.inner_text()
check("Planted" in txt and tid in txt, "the answer streams in: " + txt.replace("\n", " ")[:70])
check(pg.locator(".ag-bot .ag-text li").count() == 2 and pg.locator(".ag-bot .ag-text b").count() == 1,
"markdown list and bold render")
shot("agent-chat.jpg")
c = chats()
first, second = c[-2]["body"], c[-1]["body"]
sysmsg = first["messages"][0]
names = [t["function"]["name"] for t in first.get("tools", [])]
check(all(r["auth"] == "Bearer test-key" for r in c[-2:]), "the key reaches the model")
check(sysmsg["role"] == "system" and "isokit Studio" in sysmsg["content"] and "Nothing is selected" in sysmsg["content"]
and "project_info" in sysmsg["content"], "the system prompt has the MCP instructions and the Studio state")
check(len(names) == 17 and {"add_object", "apply_ops", "select_object", "set_view"} <= set(names),
f"the model gets 15 MCP tools + 2 page tools ({len(names)})")
check(first.get("stream") is True and first.get("model") == "mock-1", "streamed, first offered model by default")
tool_ids = [m.get("tool_call_id") for m in second["messages"] if m["role"] == "tool"]
check(tool_ids == ["call_a", "call_b"], f"tool results go back with their call ids {tool_ids}")
# the id in the answer selects the object
pg.locator(f'.ag-bot .ag-text code.ag-obj[data-obj="{tid}"]').click()
pg.wait_for_timeout(500)
check(("id " + tid) in pg.locator("#right .st-sub").inner_text(), "clicking the id selects the agent's tree")
# the selection reaches the model in the next turn; a plain answer
pg.locator("#agtext").fill("hello")
pg.locator("#agtext").press("Enter")
pg.wait_for_function("() => !document.querySelector('.ag-busy')", timeout=10000)
check(('"id":"%s"' % tid) in chats()[-1]["body"]["messages"][0]["content"], "the system prompt names the selection")
# Undo takes the agent's edit back
pg.locator("#undo").click()
pg.wait_for_timeout(700)
check(not any(o.get("name") == "Agent tree" for o in objects()), "Undo removes the agent's tree")
# Stop a long answer
pg.locator("#agtext").fill("tell me a slow story")
pg.locator("#agtext").press("Enter")
pg.wait_for_timeout(900)
check(pg.locator("#agsend").inner_text() == "Stop", "while the agent works, Send turns into Stop")
pg.locator("#agsend").click()
pg.wait_for_selector(".ag-note:has-text('Stopped')", timeout=5000)
check(pg.locator("#agsend").inner_text() == "Send" and "word1" in pg.locator(".ag-bot .ag-text").last.inner_text(),
"Stop ends the turn and keeps what came")
# settings: pick another model
def settings():
pg.wait_for_timeout(900) # a good save folds the form away after a moment
if pg.locator("#agsetf").is_hidden():
pg.locator("#agset").click()
def save():
pg.locator('#agsetf button[type="submit"]').click()
pg.wait_for_timeout(700)
settings()
check(pg.locator("#agsetf").is_visible(), "⚙ shows the settings")
opts = pg.locator("#agmodel option").all_inner_texts()
check("mock-2" in opts, f"the model list comes from the endpoint {opts}")
pg.locator("#agmodel").select_option("mock-2")
save()
pg.wait_for_function("() => /mock-2/.test(document.getElementById('agwho').textContent)", timeout=5000)
pg.locator("#agtext").fill("hello again")
pg.locator("#agtext").press("Enter")
pg.wait_for_function("() => !document.querySelector('.ag-busy')", timeout=10000)
check(chats()[-1]["body"]["model"] == "mock-2", "the chosen model is used")
if can_set_key:
settings()
pg.locator("#agkey").fill("wrong-key")
save()
check("401" in pg.locator("#agsetmsg").inner_text(), "a wrong key shows the endpoint's refusal: " + pg.locator("#agsetmsg").inner_text())
check(pg.locator("#agwho .ag-off").count() == 1, "the status dot turns red")
shot("agent-badkey.jpg")
pg.locator("#agkey").fill("test-key")
save()
check(pg.locator("#agwho .ag-on").count() == 1, "the right key connects again")
else:
settings()
check(pg.locator("#agurl").is_disabled() and "ISOKIT_LLM_KEY" in pg.locator("#agkey").get_attribute("placeholder"),
"endpoint and key set by the environment are shown as such")
shot("agent-settings.jpg")
settings()
pg.locator("#agmodel").select_option("")
save()
check("mock-1" in pg.locator("#agwho").inner_text(), "back to the first offered model")
# move and resize the window
box = pg.locator("#agwin").bounding_box()
hb = pg.locator(".ag-logo").bounding_box()
pg.mouse.move(hb["x"] + 5, hb["y"] + 5)
pg.mouse.down()
pg.mouse.move(hb["x"] - 95, hb["y"] - 35, steps=5)
pg.mouse.up()
box2 = pg.locator("#agwin").bounding_box()
check(abs(box2["x"] - box["x"] + 100) < 3 and abs(box2["y"] - box["y"] + 40) < 3, "the window moves by its title bar")
gb = pg.locator("#aggrip").bounding_box()
pg.mouse.move(gb["x"] + 4, gb["y"] + 4)
pg.mouse.down()
pg.mouse.move(gb["x"] - 56, gb["y"] - 26, steps=5)
pg.mouse.up()
box3 = pg.locator("#agwin").bounding_box()
check(abs(box3["width"] - box2["width"] - 60) < 3 and abs(box3["height"] - box2["height"] - 30) < 3, "the top-left grip resizes it")
pg.locator("#agnew").click()
check(pg.locator(".ag-empty").count() == 1 and pg.locator(".ag-tool").count() == 0, "New chat clears the conversation")
pg.locator("#agclose").click()
check(pg.locator("#agwin").is_hidden() and pg.locator("#agent").get_attribute("aria-pressed") == "false", "✕ closes the window")
+11 -2
View File
@@ -99,6 +99,10 @@ def build_info():
return ""
def html_attr(s):
return s.replace("&", "&amp;").replace('"', "&quot;").replace("<", "&lt;")
def replace_once(s, old, new):
n = s.count(old)
assert n == 1, f"studio/index.html: expected {old!r} once, found {n}"
@@ -109,6 +113,7 @@ def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--out", default=str(ROOT / "dist" / "web"))
ap.add_argument("--skip-cargo", action="store_true", help="reuse the last WASM build (front-end changes only)")
ap.add_argument("--agent-url", help="default endpoint of the agent window, e.g. a relay on the same site (/llm/v1)")
a = ap.parse_args()
out = Path(a.out).resolve()
web = out / "assets" / "web"
@@ -134,7 +139,8 @@ def main():
print(" (wasm-opt not found: the .wasm stays unoptimised by binaryen, about 15% larger)")
for src, dst in [("viewer/isokit-view.js", "viewer"), ("viewer/isokit-view.css", "viewer"),
("studio/studio.js", "studio"), ("studio/studio.css", "studio"), ("studio/favicon.svg", "studio"),
("studio/studio.js", "studio"), ("studio/agent.js", "studio"), ("studio/studio.css", "studio"),
("studio/favicon.svg", "studio"),
("web/web.js", "web"), ("web/web.css", "web")]:
(out / "assets" / dst).mkdir(parents=True, exist_ok=True)
shutil.copy2(ROOT / src, out / "assets" / dst / Path(src).name)
@@ -150,7 +156,8 @@ def main():
build = build_info()
html = replace_once(html, '<title>isokit Studio</title>',
'<title>isokit Studio</title>\n<meta name="description" content="isokit Studio: edit isometric site plans in the browser.">'
+ (f'\n<meta name="isokit-build" content="{build}">' if build else ""))
+ (f'\n<meta name="isokit-build" content="{build}">' if build else "")
+ (f'\n<meta name="isokit-agent" content="{html_attr(a.agent_url)}">' if a.agent_url else ""))
html = replace_once(html, 'href="/assets/studio/favicon.svg"', f'href="assets/studio/favicon.svg?{v}"')
html = replace_once(html, 'href="/assets/viewer/isokit-view.css"', f'href="assets/viewer/isokit-view.css?{v}"')
html = replace_once(html, '<link rel="stylesheet" href="/assets/theme.css">', '<style id="iso-theme"></style>')
@@ -158,6 +165,8 @@ def main():
f'<link rel="stylesheet" href="assets/studio/studio.css?{v}">\n<link rel="stylesheet" href="assets/web/web.css?{v}">')
html = replace_once(html, 'href="/view"', 'href="#"')
html = replace_once(html, '<script src="/assets/viewer/isokit-view.js"></script>', f'<script src="assets/viewer/isokit-view.js?{v}"></script>')
# agent.js waits for studio.js, which web.js starts once the engine is loaded
html = replace_once(html, '<script src="/assets/studio/agent.js"></script>', f'<script src="assets/studio/agent.js?{v}"></script>')
html = replace_once(html, '<script src="/assets/studio/studio.js"></script>',
f'<script type="module" src="assets/web/web.js?{v}"></script>\n'
'<noscript>The isokit Studio needs JavaScript and WebAssembly.</noscript>')
+19 -1
View File
@@ -1,10 +1,18 @@
"""Functional test of the isokit Studio (GUI) and its MCP endpoint, headless Edge."""
"""Functional test of the isokit Studio (GUI), its MCP endpoint and the agent
window, headless Edge. The agent window runs on a mock model this script starts:
ISOKIT_LLM_URL=http://127.0.0.1:7932/v1 ISOKIT_LLM_KEY=test-key ISOKIT_CONFIG_DIR=<scratch> \
isokit studio <copy of examples/demo> --port 7890 --no-open
uv run --with playwright python tools/check_studio.py <screenshot dir> http://127.0.0.1:7890
"""
import json
import sys
import urllib.request
from pathlib import Path
from playwright.sync_api import sync_playwright
import agent_checks
BASE = sys.argv[2] if len(sys.argv) > 2 else "http://127.0.0.1:7878"
OUT = Path(sys.argv[1])
OUT.mkdir(parents=True, exist_ok=True)
@@ -36,6 +44,7 @@ def obj(id_):
return next((o for o in doc()["objects"] if o["id"] == id_), None)
mock = agent_checks.start_mock()
with sync_playwright() as p:
b = p.chromium.launch(channel="msedge", headless=True)
pg = b.new_page(viewport={"width": 1500, "height": 950})
@@ -140,8 +149,17 @@ with sync_playwright() as p:
pg.locator('[data-a="rotr"]').click()
pg.wait_for_timeout(600)
pg.screenshot(path=str(OUT / "studio-gf.jpg"), type="jpeg", quality=80)
# the agent window, on the mock model (the Studio must run with ISOKIT_LLM_URL=<mock>/v1, see the docstring)
cfg = get("/api/agent/config")
if agent_checks.MOCK not in cfg["url"]:
check(False, "agent window checks need the Studio started with ISOKIT_LLM_URL=" + agent_checks.MOCK + "/v1 (found " + cfg["url"] + ")")
else:
check(not cfg.get("key") and cfg["has_key"] and cfg["key_from"] == "env", "/api/agent/config says a key is set, without the key")
pg.locator('.iso-seg button[data-level="site"]').click()
agent_checks.run(pg, lambda: doc()["objects"], lambda n: pg.screenshot(path=str(OUT / n), type="jpeg", quality=80), check, can_set_key=False)
check(not errs, f"no script errors {errs}")
b.close()
mock.terminate()
print("FAILED:" if fails else "ALL PASSED", fails)
sys.exit(1 if fails else 0)
+15 -2
View File
@@ -7,8 +7,9 @@ Serves the build on a local port and checks: the demo loads from the bundled
files, placing / dragging / props / undo, persistence across reloads, the code
editor (script error and repair), new / export / import (.zip, stored and
deflated), page build and preview, MCP in the page and WebMCP, two tabs on one
project, and a folder project (the origin-private file system stands in for a
folder picked with showDirectoryPicker: same handle API) following outside edits.
project, a folder project (the origin-private file system stands in for a
folder picked with showDirectoryPicker: same handle API) following outside
edits, and the agent window on a mock model (tools/mock_llm.py, started here).
"""
import io
import json
@@ -20,6 +21,8 @@ from pathlib import Path
from playwright.sync_api import sync_playwright
import agent_checks
ROOT = Path(__file__).resolve().parent.parent
OUT = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "dist" / "web-shots"
DIST = Path(sys.argv[2]) if len(sys.argv) > 2 else ROOT / "dist" / "web"
@@ -60,6 +63,7 @@ WEBMCP_STUB = "navigator.modelContext = { provideContext: function (c) { window.
srv = subprocess.Popen([sys.executable, "-m", "http.server", str(PORT), "--bind", "127.0.0.1", "--directory", str(DIST)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
mock = agent_checks.start_mock()
time.sleep(1.0)
try:
with sync_playwright() as p:
@@ -186,6 +190,12 @@ try:
return t.execute({id: "agent-lamp", tag: "via WebMCP"}); }""")
check(r["isError"] is False and obj(pg, "agent-lamp")["tag"] == "via WebMCP", "a WebMCP tool call edits the scene")
# ---------------------------------------------------------------- agent window (mock model, key kept in the browser)
pg.evaluate("(u) => localStorage.setItem('isokit-agent', JSON.stringify({url: u, key: 'test-key'}))", agent_checks.MOCK + "/v1")
agent_checks.run(pg, lambda: doc(pg)["objects"], lambda n: shot(pg, "web-" + n), check, can_set_key=True)
stored = pg.evaluate("() => JSON.parse(localStorage.getItem('isokit-agent'))")
check(stored.get("key") == "test-key" and not stored.get("model"), f"settings stay in this browser {stored}")
# ---------------------------------------------------------------- two tabs on one project
pg2 = ctx.new_page()
watch(pg2)
@@ -309,10 +319,13 @@ try:
pg.wait_for_timeout(400)
shot(pg, "web-narrow.jpg")
# the browser logs the 401 of the agent window's wrong-key check
errs = [e for e in errs if "status of 401" not in e]
check(not errs, f"no page errors {errs[:5]}")
b.close()
finally:
srv.terminate()
mock.terminate()
print("\n%d failure(s)" % len(fails))
sys.exit(1 if fails else 0)
+131
View File
@@ -0,0 +1,131 @@
"""A stand-in for an OpenAI-compatible model server, for the agent window tests.
python tools/mock_llm.py [port] (default 7932; base URL http://127.0.0.1:<port>/v1)
Wants the key `test-key`. Answers like a model that uses tools:
- a user message "plant a tree at X, Y" -> reasoning, then two tool calls:
list_objects {type: tree} and add_object {type: tree, x: X, y: Y, name: "Agent tree"}
- tool results -> a short answer naming the new object's id in backticks
- a user message containing "slow" -> a long, slow answer (to test Stop)
- anything else -> a plain answer
Streams (SSE) when asked to. Every page origin may call it (CORS, also the
preflight). GET /_requests lists the requests seen (headers and bodies).
"""
import json
import re
import sys
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
KEY = "test-key"
SEEN = []
class H(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *a):
pass
def cors(self):
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Headers", "authorization, content-type")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
def reply(self, code, obj):
b = json.dumps(obj).encode()
self.send_response(code)
self.cors()
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(b)))
self.end_headers()
self.wfile.write(b)
def authorized(self):
if self.headers.get("Authorization") == "Bearer " + KEY:
return True
self.reply(401, {"error": {"message": "invalid api key"}})
return False
def do_OPTIONS(self):
self.send_response(204)
self.cors()
self.send_header("Content-Length", "0")
self.end_headers()
def do_GET(self):
if self.path == "/_requests":
return self.reply(200, SEEN)
if self.path == "/v1/models":
if self.authorized():
self.reply(200, {"object": "list", "data": [{"id": "mock-1", "object": "model"}, {"id": "mock-2", "object": "model"}]})
return
self.reply(404, {"error": {"message": "not found"}})
def do_POST(self):
n = int(self.headers.get("Content-Length") or 0)
body = json.loads(self.rfile.read(n) or b"{}")
SEEN.append({"path": self.path, "auth": self.headers.get("Authorization"), "body": body})
if self.path != "/v1/chat/completions":
return self.reply(404, {"error": {"message": "not found"}})
if not self.authorized():
return
msgs = body.get("messages", [])
last = msgs[-1] if msgs else {}
deltas, finish = [], "stop"
if last.get("role") == "user" and "slow" in last.get("content", ""):
deltas = [{"content": "word%d " % i} for i in range(80)]
return self.stream(body, deltas, finish, pause=0.1)
m = re.search(r"tree at (-?[\d.]+),\s*(-?[\d.]+)", last.get("content", "")) if last.get("role") == "user" else None
if m:
x, y = float(m.group(1)), float(m.group(2))
deltas = [{"reasoning": "The user wants a tree. "}, {"reasoning": "I look at the trees first, then add one."},
{"tool_calls": [{"index": 0, "id": "call_a", "type": "function", "function": {"name": "list_objects", "arguments": ""}}]},
{"tool_calls": [{"index": 0, "function": {"arguments": '{"type": "tree"}'}}]},
{"tool_calls": [{"index": 1, "id": "call_b", "type": "function", "function": {"name": "add_object", "arguments": '{"type": "tree", '}}]},
{"tool_calls": [{"index": 1, "function": {"arguments": '"x": %s, "y": %s, "name": "Agent tree"}' % (x, y)}}]}]
finish = "tool_calls"
elif last.get("role") == "tool":
new_id = "?"
for mm in reversed(msgs):
if mm.get("role") == "tool":
try:
new_id = json.loads(mm["content"]).get("id") or new_id
except (ValueError, AttributeError):
pass
if new_id != "?":
break
deltas = [{"content": "Planted "}, {"content": "`%s` for you." % new_id}, {"content": "\n\n- one tree\n- **no** overlaps"}]
else:
deltas = [{"content": "Hello from the mock model."}]
self.stream(body, deltas, finish)
def stream(self, body, deltas, finish, pause=0.02):
if not body.get("stream"):
msg = {"role": "assistant", "content": "".join(d.get("content", "") for d in deltas)}
return self.reply(200, {"choices": [{"index": 0, "message": msg, "finish_reason": finish}]})
self.send_response(200)
self.cors()
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.send_header("Connection", "close")
self.end_headers()
try:
for d in [{"role": "assistant", "content": ""}] + deltas:
ch = {"object": "chat.completion.chunk", "model": body.get("model") or "mock-1",
"choices": [{"index": 0, "delta": d, "finish_reason": None}]}
self.wfile.write(("data: %s\n\n" % json.dumps(ch)).encode())
self.wfile.flush()
time.sleep(pause)
end = {"object": "chat.completion.chunk", "choices": [{"index": 0, "delta": {}, "finish_reason": finish}]}
self.wfile.write(("data: %s\n\ndata: [DONE]\n\n" % json.dumps(end)).encode())
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
SEEN.append({"aborted": True})
self.close_connection = True
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 7932
print(f"mock model on http://127.0.0.1:{port}/v1", flush=True)
ThreadingHTTPServer(("127.0.0.1", port), H).serve_forever()